Model Accuracy & AI Performance — Senior Staff Engineer Curriculum
Target Roles:
- Qualcomm — Senior Staff Machine Learning Engineer, AI Frameworks & Performance
- Apple — Senior Staff Engineer, Core ML Accuracy & Inference
- NVIDIA — Principal Engineer, Model Optimization & Accuracy
- Google DeepMind — Senior Staff Engineer, ML Systems Performance
- Meta — Staff Engineer, AI Infrastructure & Model Quality
- Microsoft — Principal Engineer, AI Model Optimization
- Arm — Senior Staff ML Compiler & Accuracy Engineer
- AMD — Principal Engineer, ROCm Model Optimization
- Cerebras / Groq / Tenstorrent — Staff ML Systems & Accuracy Engineer
Duration: 24 weeks core (6 months) — extendable to 12 months with advanced research projects
Seniority Target: Senior Staff / Principal IC (L6–L7 equivalent)
Goal: Equip you to lead end-to-end model accuracy and AI performance work on NPU/GPU/CPU hardware — from ML framework internals through hardware-aware graph optimization, quantization with accuracy recovery, profiling, eval systems, and production deployment on edge silicon.
Why This Curriculum Exists
The "Model Accuracy & AI Performance" discipline sits at the hardest intersection in AI engineering: you must simultaneously understand model internals deeply enough to reason about why accuracy changes after optimization, and hardware/compiler internals deeply enough to know where performance is left on the table. Most engineers are strong in one dimension; Senior Staff engineers are expected to own both.
The Qualcomm JD (and every equivalent role at other silicon companies) makes this explicit: they want someone who can take a research-grade PyTorch model, understand its architecture from literature, apply quantization and graph transformations, run it through a compiler toolchain targeting an NPU, profile the result, recover lost accuracy, and repeat — all while influencing the ML framework design itself. This requires 12+ years of compounding depth.
This curriculum builds that depth from first principles. Every phase ends with quantified artifacts you can demo in interviews and publish in your GitHub portfolio. The final capstone projects are designed to be genuine open-source contributions, not toy exercises.
Reference Job Targets
| Company | Role | Phases that map directly |
|---|---|---|
| Qualcomm | Senior Staff ML Engineer — AI Frameworks | All phases; P06 is differentiating |
| Apple | Sr. Staff Engineer — Core ML Accuracy | P01, P03, P04, P09, P10 |
| NVIDIA | Principal Engineer — TensorRT Optimization | P01, P04, P05, P07, P08 |
| Sr. Staff — JAX/XLA Performance | P01, P04, P05, P07 | |
| Meta | Staff Engineer — PyTorch Core | P01, P02, P04, P07 |
| Arm | Sr. Staff — ML IP & Compiler | P04, P05, P06, P07 |
What You Will Build
By the end of this curriculum you will have shipped and can demo:
- Custom autograd engine — implement reverse-mode autodiff from scratch in Python+C++, matching PyTorch semantics on standard test cases within 1e-8 numerical tolerance
- Custom C++ PyTorch operator — registered via
TORCH_LIBRARY, with CPU+CUDA kernels,torch.compile-compatible, passing allOpChecktests - Transformer with full architectural variants — RoPE, ALiBi, GQA, MoE routing, all swappable — benchmarked on Wikitext-103
- Full PTQ → QAT → GPTQ → AWQ pipeline — automated, with accuracy-latency Pareto curves and regression gating
torch.compilecustom backend — FX graph lowering to a mock NPU ISA with >95% op coverage on LLaMA-style models- TVM compilation pipeline — Relay → TIR → ARM/x86 compiled module with AutoTVM tuning, 2x+ speedup over baseline
- SNPE/QNN model deployment pipeline — ONNX → DLC → quantized NPU inference with full accuracy benchmarking vs FP32 baseline
- Roofline analysis toolkit — measure model arithmetic intensity, plot compute/memory ceilings, automatically identify bottleneck ops
- FlashAttention v2 from scratch in Triton — tiled SRAM-efficient implementation matching official FA2 output within 1e-5
- Speculative decoding engine — draft + verifier with acceptance rate profiling, 2–4x throughput improvement demonstrated
- Eval harness from scratch — MMLU, HellaSwag, GSM8K, perplexity; pluggable task registry, 3 sampling strategies, statistical significance reporting
- Accuracy regression CI — PR-gate pipeline that fails builds if accuracy drops >0.5% on key tasks after any optimization change
- On-device LLM end-to-end — LLaMA-3.2-1B → INT4 quantized → edge-benchmarked with accuracy delta vs FP16 documented
- Multi-model evaluation dashboard — Pareto frontier visualization across quantization levels, latency budgets, and accuracy metrics
Folder Structure
model-accuracy-ai-performance/
├── README.md ← this file
├── phase-01-pytorch-tf-framework-internals/
│ ├── README.md
│ ├── WARMUP.md ← zero-to-expert primer
│ ├── HITCHHIKERS-GUIDE.md
│ ├── lab-01-custom-autograd-engine/
│ ├── lab-02-custom-cpp-operator/
│ └── lab-03-tf-function-tracing/
├── phase-02-model-architecture-mastery/
│ ├── README.md
│ ├── HITCHHIKERS-GUIDE.md
│ ├── DEEP-DIVE-MOE-SSM.md ← MoE + Mamba from first principles
│ ├── lab-01-transformer-architectural-variants/
│ ├── lab-02-mixture-of-experts/
│ └── lab-03-state-space-models/
├── phase-03-quantization-model-compression/
│ ├── README.md
│ ├── HITCHHIKERS-GUIDE.md
│ ├── DEEP-DIVE-QAT-GPTQ.md ← PTQ/QAT/GPTQ from first principles
│ ├── lab-01-ptq-pipeline/
│ ├── lab-02-qat/
│ └── lab-03-gptq/
├── phase-04-torch-compile-fx-graph/
│ ├── README.md
│ ├── HITCHHIKERS-GUIDE.md
│ ├── lab-01-fx-graph-passes/
│ ├── lab-02-custom-compile-backend/
│ └── lab-03-triton-fused-kernel/
├── phase-05-ml-compilers-ir/
│ ├── README.md
│ ├── HITCHHIKERS-GUIDE.md
│ ├── lab-01-onnx-export-optimization/
│ ├── lab-02-tvm-compilation/
│ └── lab-03-mlir-python-bindings/
├── phase-06-qualcomm-npu-hardware/
│ ├── README.md
│ ├── HITCHHIKERS-GUIDE.md
│ ├── lab-01-ai-hub-deployment/
│ ├── lab-02-hardware-aware-quantization/
│ └── lab-03-on-device-profiling/
├── phase-07-profiling-performance-engineering/
│ ├── README.md
│ ├── HITCHHIKERS-GUIDE.md
│ ├── lab-01-roofline-analyzer/
│ ├── lab-02-profiling-context/
│ └── lab-03-transformer-perf-bugs/
├── phase-08-inference-optimization-depth/
│ ├── README.md
│ ├── HITCHHIKERS-GUIDE.md
│ ├── lab-01-flash-attention/
│ ├── lab-02-speculative-decoding/
│ └── lab-03-continuous-batching/
├── phase-09-model-accuracy-evaluation/
│ ├── README.md
│ ├── HITCHHIKERS-GUIDE.md
│ ├── DEEP-DIVE-EVALUATION-AT-SCALE.md ← Pareto, McNemar, CI gates
│ ├── lab-01-eval-harness/
│ ├── lab-02-pareto-analyzer/
│ └── lab-03-regression-ci/
├── phase-10-genai-on-edge/
│ ├── README.md
│ ├── HITCHHIKERS-GUIDE.md
│ ├── lab-01-awq-quantization/
│ ├── lab-02-qlora-adapter/
│ └── lab-03-layer-streaming/
├── phase-11-capstone/
│ ├── README.md
│ ├── HITCHHIKERS-GUIDE.md
│ ├── lab-01-regression-ci-pipeline/
│ ├── lab-02-npu-graph-optimization/
│ ├── lab-03-custom-compile-backend/
│ ├── lab-04-production-quant-suite/
│ └── lab-05-multi-model-dashboard/
├── interview-prep/
│ ├── README.md
│ ├── 01-concepts-cheatsheet.md
│ ├── 02-framework-internals-coding.py
│ ├── 03-optimization-coding.py
│ ├── 04-systems-design-questions.md
│ ├── 05-research-engineering-questions.md
│ └── 06-behavioral-star-stories.md
└── system-design/
├── README.md
├── 01-model-accuracy-regression-platform.md
├── 02-npu-graph-optimization-pipeline.md
├── 03-quantization-accuracy-recovery-system.md
├── 04-distributed-eval-platform.md
└── 05-model-perf-benchmarking-infra.md
24-Week Schedule
| Week | Phase | Focus |
|---|---|---|
| 1–2 | 01 | PyTorch dispatch stack, autograd engine from scratch |
| 3–4 | 01 | C++ custom operator, TF tf.function tracing mechanics |
| 5–6 | 02 | Transformer architectural variants (RoPE/ALiBi/GQA/MoE) |
| 7 | 02 | ViT + DINO; multimodal CLIP→LLM bridge |
| 8–9 | 03 | PTQ pipeline + QAT from scratch, calibration strategies |
| 10–11 | 03 | GPTQ, AWQ, SmoothQuant; accuracy-latency Pareto curves |
| 12 | 04 | TorchDynamo internals, FX graph transformation passes |
| 13 | 04 | Custom torch.compile backend + Triton fused kernels |
| 14 | 05 | ONNX export pipeline + optimization passes |
| 15 | 05 | TVM Relay→TIR pipeline + MLIR dialect intro |
| 16 | 06 | Qualcomm SNPE/QNN architecture + deployment pipeline |
| 17 | 06 | Hardware-aware graph partitioning + operator mapping |
| 18 | 07 | Roofline model theory + analysis tool from scratch |
| 19 | 07 | Custom profiling harness + bottleneck identification |
| 20 | 08 | FlashAttention v2 from scratch in Triton |
| 21 | 08 | Speculative decoding + continuous batching engine |
| 22 | 09 | Eval harness + accuracy regression pipeline |
| 23 | 10 | On-device LLM + LoRA accuracy recovery |
| 24 | 11 | Capstone integration projects + interview prep review |
Each Lab Structure
| File | Purpose |
|---|---|
README.md | Theory, math derivations, design rationale, interview Q&A, talking points |
lab.py | Guided exercise with # TODO markers — you fill in the blanks |
solution.py | Complete reference solution with inline commentary |
requirements.txt | Pinned pip dependencies |
test_lab.py | Automated tests validating correctness of your solution |
Prerequisites
- Python 3.10+ with pip
- PyTorch 2.3+ (
pip install torch torchvision) - CUDA 12.x (or MPS on Apple Silicon; labs work on CPU with reduced scale)
- CMake 3.20+ and g++ / clang for C++ labs
- Triton 2.3+ (comes with PyTorch nightly for some labs)
- Basic familiarity with: calculus (chain rule), linear algebra, Python, C++ basics
- Git — all labs tracked in version control
- Optional: Qualcomm AI Hub account (free) for Phase 06 simulator runs
Hardware Recommendations
| Tier | Hardware | What runs |
|---|---|---|
| Minimum | CPU only (8GB RAM) | Phases 01–05, scaled-down labs |
| Recommended | Single RTX 3090/4090 (24GB VRAM) | All phases at full scale |
| Ideal | A100 80GB or H100 | Phase 08 FlashAttention, Phase 11 capstones |
| Edge simulation | Snapdragon DevKit or AI Hub cloud | Phase 06, Phase 10 |
Glossary — Model Accuracy & AI Performance
Every term the track (and the Qualcomm Senior Staff JD) assumes, defined from first principles. Grouped by phase area. Cross-references in [[brackets]] point to related entries.
Framework Internals (P01)
- Dispatcher (
c10::Dispatcher) — PyTorch's C++ router that, for every operator call, selects the kernel based on dispatch keys. The layer between the Python API and the backend kernels. - Dispatch key — a tag (CPU, CUDA, Autograd, Functionalize, Meta, …) that determines which registered kernel handles an op. A custom op must behave correctly under all relevant keys.
- Autograd engine — builds a DAG during the forward pass (each op records its backward) and
walks it in reverse-topological order on
.backward(), accumulating gradients at the leaves. AccumulateGrad— the autograd node that sums gradients into a leaf tensor's.grad.retain_graph/create_graph— keep the graph after backward (for multiple backwards) / build a differentiable backward graph (for higher-order gradients / double backward).torch.autograd.Function— a custom op with hand-writtenforward/backward;save_for_backwardstashes tensors for the backward pass.TORCH_LIBRARY/TORCH_LIBRARY_IMPL— C++ macros to declare an op schema and register a kernel for a given dispatch key.- Meta kernel — a kernel that computes only shapes/dtypes (no data), used for tracing,
torch.compile, and shape inference. Missing it is a common custom-op bug. - Functionalization — a pass that rewrites in-place/mutating ops into functional equivalents so the graph is pure (needed by compile/export).
tf.function— TensorFlow's tracing decorator: runs the Python once to build a graph (FuncGraph), then executes the graph. Python side-effects happen at trace time only.- Retracing — TF re-runs tracing when it sees a new input signature; excessive retracing is a performance bug.
- BF16 / FP16 / FP32 — brain-float16 (8-bit exponent, 7-bit mantissa — FP32's range, less precision), half (5-bit exp, 10-bit mantissa), single. [[accumulation-dtype]].
- Catastrophic cancellation — large precision loss when subtracting nearly-equal floats; a driver of low-precision instability.
Model Architecture (P02)
- Self-attention — each token attends to all others; cost is O(N²) in sequence length N (the motivation for [[FlashAttention]] and sparse/linear variants).
- FFN / MLP block — the per-token feed-forward in a transformer; the bulk of the params.
- Mixture-of-Experts (MoE) — replace the dense FFN with N experts; a router sends each token to the top-k experts. Cuts FLOPs/token but adds a dynamic gather/scatter the NPU dislikes.
- Load-balancing loss — auxiliary loss that prevents MoE router collapse (a few experts hogging all tokens). Without it, "8 experts" becomes effectively 2.
- State-Space Model (SSM) / Mamba — sequence model using a selective scan recurrence with near-linear cost instead of quadratic attention; the scan is sequential (hardware tradeoff).
- ViT (Vision Transformer) — images as patch sequences fed to a transformer; trades CNN inductive bias for scale.
- DINO — self-supervised distillation: an EMA teacher trains a student with no labels; centering + sharpening prevent representation collapse.
- CLIP bridge — projecting vision encoder features into an LLM's token embedding space to make a multimodal model.
Quantization & Compression (P03, P10)
- Quantization — mapping FP values to a small set of integers via a scale and zero-point.
- Scale (s) — the step size:
s = (x_max − x_min) / (q_max − q_min). Smaller scale → smaller error. - Zero-point (z) — the integer that maps to real 0 (asymmetric quant). Symmetric quant has z = 0.
- Symmetric vs asymmetric — range centered at 0 (z=0, simpler hardware) vs arbitrary [min,max] (uses z). Watch the dtype: 8-bit unsigned is [0,255], signed is [−128,127].
- Per-tensor vs per-channel — one scale for the whole tensor vs one per output channel (per-channel is more accurate; the hardware must support the layout).
- PTQ (Post-Training Quantization) — quantize a trained model using a little calibration data. Fast; first resort.
- QAT (Quantization-Aware Training) — simulate quantization during training (fake-quant forward, [[straight-through-estimator]] backward) so the model learns robustness. For when PTQ drops too much.
- Straight-through estimator (STE) — treat the non-differentiable round() as identity in the backward pass so gradients flow through fake-quant.
- Calibration — choosing the clipping range from observed activations (min/max, percentile, MSE-optimal, entropy/KL).
- GPTQ — second-order weight quantization: quantize column-by-column, compensating remaining weights for the error using the (approx) Hessian. Holds accuracy at 4-bit.
- AWQ (Activation-aware Weight Quantization) — protect the small set of salient weight channels (found via activation magnitudes) and crush the rest to 4-bit.
- NF4 — a 4-bit "normal float" datatype tuned to the normal distribution of NN weights (used by [[QLoRA]]).
- QLoRA — fine-tune a frozen 4-bit base model via small high-precision low-rank adapters.
- Mixed precision — keep sensitive layers high-precision, crush the rest; the real size/accuracy win.
- Sensitivity analysis — quantize one layer at a time, measure the accuracy hit, rank layers.
Compilers & IR (P04, P05)
torch.compile— PyTorch's JIT: [[Dynamo]] captures the graph, a backend lowers it.- TorchDynamo — captures Python bytecode into an [[FX graph]]; graph-breaks on code it can't
trace (data-dependent control flow,
.item(), unsupported ops). - Graph break — Dynamo splitting a function into multiple graphs with eager glue; the #1
reason
torch.compileunderdelivers. - TorchInductor — the default backend; lowers FX to Triton (GPU) / C++ (CPU) kernels.
- FX graph — PyTorch's inspectable IR of nodes (
placeholder,call_function,call_module,call_method,output). Dialect matters:symbolic_traceof modules givescall_module; aot/make_fxgivesatenops. - Operator fusion — combining ops (e.g. Linear→GELU) into one to cut memory traffic / kernel launches; only safe when the producer's output has a single consumer.
- Custom backend — a function registered with Dynamo that receives an FX
GraphModule, transforms it (classify NPU-eligible nodes, offload, fall back to CPU), and returns a callable. - ONNX — Open Neural Network Exchange; a cross-framework model interchange format. Export can drop/decompose ops — verify numerically after export.
- opset — the ONNX operator-set version; mismatches cause export/runtime failures.
- TVM — an ML compiler with auto-tuning (searches loop schedules for the fastest kernel on a given target). IRs: Relay (high-level), TIR (low-level).
- MLIR — Multi-Level IR: a compiler infrastructure of progressively-lowered dialects; the backbone of many modern hardware compilers.
- Lowering — translating a higher-level IR to a lower-level one (coarse ops → fine loops); each step optimizes and risks bugs.
Hardware / NPU (P06)
- NPU (Neural Processing Unit) — a fixed-function accelerator for tensor math; on Qualcomm, the Hexagon Tensor Processor (HTP) in the AI Engine. Loves static, dense, fixed-point work.
- Coverage / offload rate — fraction of model compute that runs on the NPU vs falls back to CPU. The key health metric; low coverage silently wrecks latency/power.
- CPU fallback — running an unsupported op on the CPU mid-graph; the round-trip is expensive.
- Fixed-point accumulator — the NPU's integer accumulator; finite width → can overflow if scales are mis-set.
- AI Hub — Qualcomm's service for compiling/profiling/deploying models to target devices.
- Operator coverage / workload synthesis — ensuring (or making) every op in a model runnable on the hardware; co-design feedback to the HW team.
- Energy per inference (joules) — the real edge budget; power and latency couple via thermal throttling.
Profiling & Inference (P07, P08)
- Roofline model — plots achievable performance vs arithmetic intensity (FLOPs/byte); classifies a kernel as compute-bound or memory-bound, which dictates the fix.
- Arithmetic intensity — FLOPs per byte of memory traffic; low intensity → memory-bound.
- Compute-bound — limited by FLOPs/s; helped by faster math, not by moving fewer bytes.
- Memory-bound — limited by bytes/s; helped by fusion, quantization, layout — not by more FLOPs. Most LLM inference is here.
- CPU↔device sync — a
.item()/.cpu()in a hot loop that serializes execution; the most common "accelerator at 30% utilization" cause. - Warmup — the first iterations (compile/alloc/cache) you must discard before timing steady-state; report the median of many runs.
- FlashAttention — computes attention in tiles using online softmax, never materializing the N×N matrix; cuts memory traffic O(N²)→O(N). Exact, not approximate.
- Online softmax — running-max + running-sum recurrence that lets softmax be computed incrementally over tiles.
- KV cache — cached keys/values for past tokens so autoregressive decode is O(1)/token in attention; grows with sequence length (a memory killer; can be quantized/paged).
- Speculative decoding — a small draft model proposes several tokens; the big model verifies them in one parallel pass. Exact (identical output), pure latency win.
- Continuous batching — evicting finished sequences and admitting new ones every step to keep the accelerator saturated (server-side throughput).
- Paged KV cache (vLLM) — managing KV memory in fixed pages to avoid fragmentation.
Accuracy & Evaluation (P09)
- Pareto frontier — the set of models where you can't gain accuracy without adding latency; deployment picks a point on it.
- Knee point — the frontier's point of diminishing returns; usually the deployment sweet spot.
- Per-example delta / churn — which specific inputs changed verdicts between two models; hides regressions a matching aggregate score conceals.
- Regression CI — automated accuracy gates that block a merge if an "optimization" drops accuracy below threshold.
- Bootstrap CI / McNemar's test — statistical tools to decide whether an accuracy difference is real vs noise (paired model comparison).
- Reproducibility — same seed/data/config → same number; the precondition for any comparison.
The numbers behind these terms (bit-widths, quant formulas, roofline math, memory sizing) live in CHEATSHEET.md.
Cheat Sheet — Model Accuracy & AI Performance
The numbers, formulas, and decision rules to have at your fingertips — the interview-whiteboard quick reference. Definitions are in GLOSSARY.md.
Quantization formulas
Affine (asymmetric): Q(x) = clamp(round(x/s) + z, q_min, q_max)
x̂ = s · (Q(x) − z)
Scale: s = (x_max − x_min) / (q_max − q_min)
Zero-point: z = q_min − round(x_min / s)
Symmetric (z=0): s = max(|x|) / q_max
Quant error bound: |Q(x) − x| ≤ s/2 → smaller s = smaller error
| dtype | range | notes |
|---|---|---|
| INT8 signed | −128 … 127 | symmetric default |
| INT8 unsigned | 0 … 255 | asymmetric; don't cast to int8 before dequant — overflow! |
| INT4 | −8 … 7 (or 0 … 15) | weight-only (GPTQ/AWQ); needs error comp / salient protection |
| NF4 | 16 normal-spaced levels | QLoRA base dtype |
PTQ vs QAT: try PTQ first (fast, little data). Reach for QAT when PTQ's accuracy drop is too big (often INT4, or sensitive layers). Mixed precision (sensitive layers high, rest low) is usually the best size/accuracy point.
Memory math: params × bytes/param. 7B model → FP16 ≈ 14 GB, INT8 ≈ 7 GB, INT4 ≈ 3.5 GB. (Plus the KV cache, which grows with sequence length — quantize/bound it on edge.)
Floating-point formats
| format | exp bits | mantissa bits | use |
|---|---|---|---|
| FP32 | 8 | 23 | baseline / accumulation |
| FP16 | 5 | 10 | range-limited, precise-ish |
| BF16 | 8 | 7 | FP32's range, less precision — training-stable |
Rule: accumulate in higher precision than you store. Reductions (softmax, sum) in FP32 even when activations are FP16/BF16.
Roofline (the master diagram)
Arithmetic Intensity (AI) = FLOPs / bytes moved
Attainable perf = min( peak_FLOPs/s , peak_BW × AI )
AI low → MEMORY-bound → fix: fusion, quantization, layout, FlashAttention
AI high → COMPUTE-bound → fix: better matmul, more cores, higher clocks
Most LLM inference is memory-bound (weight loading at batch 1; the N×N attention matrix) — which is why FlashAttention and quantization win. Don't optimize FLOPs on a memory-bound kernel.
Profiling discipline
1. WARM UP (discard first iters — compile/alloc/cache)
2. measure MANY iters, take the MEDIAN (not mean — outliers)
3. know your clock: CPU time ≠ device time ≠ wall time; self ≠ total
4. find the regime (roofline) BEFORE choosing a fix
Top recurring perf bugs: CPU↔device sync (.item()/.cpu() in a hot loop), hidden dtype
casts, non-contiguous copies from reshapes, kernel-launch overhead (many tiny ops),
unfused elementwise chains.
Inference optimizations (when each wins)
| Technique | Wins on | Idea |
|---|---|---|
| FlashAttention | long seq, memory-bound | tile + online softmax; never materialize N×N. Exact. |
| KV cache | autoregressive decode | reuse past K/V; O(1)/token attention (memory grows w/ len) |
| Speculative decoding | latency, batch 1 | small draft proposes, big model verifies in parallel. Exact. |
| Continuous batching | server throughput | evict finished + admit new each step; keep HW saturated |
| Quantization | memory-bound / fit | move fewer bytes; fit the model in memory |
Edge (batch 1, power-limited): FlashAttention ✅, quantization ✅✅, KV-cache quant ✅, spec-decode (if draft fits memory) ☑, continuous batching ➖ (server-side).
FX graph dialects (the silent bug)
| capture | node ops you see | example target |
|---|---|---|
symbolic_trace(module) | call_module | "l1", "act" |
| Dynamo (default) | call_function (torch fns) | torch.relu, torch._C._nn.linear |
make_fx / aot | call_function (aten) | aten.linear.default |
A pass that matches the wrong dialect silently does nothing (no fusion, 0 NPU nodes). Match by function identity AND aten string to be robust. Fuse only when the producer has 1 user.
NPU mental model (Qualcomm)
LOVES : static shapes · dense regular dataflow · fixed-point (INT8/INT4) · supported ops · on-chip
HATES : dynamic shapes · data-dependent control flow · gather/scatter (MoE) · unsupported ops · DRAM round-trips
- Coverage = % compute on NPU. Treat as a vital sign; one unsupported hot op → CPU fallback → latency death.
- Fixed-point accumulator can overflow — size scales carefully.
- Budget is joules, not just ms (power ↔ thermal throttle ↔ latency).
- Sim validates accuracy; trust the device for performance; reconcile when they diverge.
Consistency: every optimization must be verified
quantize → eval accuracy delta (P09)
compile/fuse/export → numerical-equivalence check vs eager (atol/rtol)
spec-decode → bit-exact to base model
deploy to NPU → eval ON DEVICE (numerics differ from sim)
Fast-but-wrong is a shipped bug. No optimization is "done" until its accuracy is proven.
Evaluation rigor
report: score + variance/CI + baseline delta + per-example churn (not a lone number)
reproducible: fixed seed/data/config
is-it-real?: bootstrap CI / McNemar (paired); effect must exceed the noise band
Pareto: accuracy vs latency is a FRONTIER; ship at the KNEE for the device's budget
regression CI: block merges that drop accuracy below threshold
The lab verification convention (this track)
# tests import `lab` (your code) if present, else `solution`:
pytest test_*.py # against your lab.py (NotImplementedError until you fill it)
# to verify the reference solution, shadow lab.py with solution.py, then pytest.
Heavy/proprietary deps (C++ toolchain, TensorFlow, TVM, Qualcomm AI Hub SDK) make a few labs skip gracefully rather than fail — read the code and run them where the toolchain exists.
Phase 01 — PyTorch & TensorFlow Framework Internals
Difficulty: ⭐⭐⭐⭐☆
Estimated Time: 4 weeks (80–100 hours)
Roles Supported: All — this is the bedrock phase. Every downstream topic (quantization, torch.compile, NPU deployment) requires internalizing what happens below the Python API.
Why This Phase Exists
Every Senior Staff ML engineer who influences Qualcomm's framework stack will be asked — in an interview or on the first week of the job — to trace execution from loss.backward() down to the CUDA kernel. If you can't do that, you cannot meaningfully review PRs touching the dispatcher, write a custom operator that behaves correctly under torch.compile, or diagnose why a quantized model produces NaN after a specific graph transformation.
PyTorch's execution model has three distinct layers that every practitioner conflates: the Python eager frontend, the dispatcher (C++ c10), and the backend kernels. TensorFlow's execution model has evolved through three paradigms (eager, tf.function + AutoGraph, tf.experimental.mlir_bridge) that coexist in production code. Understanding both frameworks at this depth is what separates framework users from framework engineers.
This phase will make you dangerous enough to write test cases that expose correctness bugs in framework patches, write custom ops that are safe under all dispatch keys (CPU, CUDA, autograd, torch.compile, functional), and reason about TF's tracing semantics when a colleague reports that "the model gives different results in graph mode."
Concepts
- PyTorch dispatch stack: Python → C++ dispatcher (
c10::Dispatcher) → dispatch keys (CPU, CUDA, Autograd, Functionalize, Meta) → kernel registration viaTORCH_LIBRARY/TORCH_LIBRARY_IMPL - Autograd engine: DAG construction during forward pass,
AccumulateGrad,Engine::execute(), gradient accumulation,retain_graph,create_graphfor higher-order gradients torch.autograd.Function: customforward/backward,save_for_backward, composite functions, double backward- Eager vs compiled mode: what
torch.compilechanges at the boundary —TorchDynamocaptures bytecode into FX IR,TorchInductorlowers to Triton/C++ torch.fxsymbolic tracing:Interpreter,Transformer,Graph,Node, proxy mechanism, limitations (control flow, data-dependent shapes)- Custom C++ operators:
TORCH_LIBRARYschema,TORCH_LIBRARY_IMPL, pybind11 vstorch::Library,OpCheck, structured kernels - TF graph execution:
Session.run()(TF1) vstf.function+tf.Graph(TF2),ConcreteFunction,FuncGraph, AutoGraph transformation rules tf.functiontracing semantics: Python-side effects,tf.Variablevs Python variables, retracing triggers (new input signatures, Python closures)- TF SavedModel format:
tf.saved_model.save,SignatureDef, asset handling, interop with TFLite/ONNX - Memory management: PyTorch caching allocator, CUDA unified memory, TF tensor lifetimes in sessions vs eager
- Numerical precision: FP32 vs FP16 vs BF16 accumulation, catastrophic cancellation, Kahan summation, determinism flags
- Operator decomposition: how PyTorch's
_decompregistry breaks complex ops into primitive ops for portability
Labs
Lab 01 — Custom Autograd Engine from Scratch
| Field | Value |
|---|---|
| Goal | Implement a mini automatic differentiation library (minigrad) that supports reverse-mode autodiff over a computation graph, passing numerical gradient checks against PyTorch on 15 standard test cases |
| Concepts | Reverse-mode AD, topological sort, Function subclassing, Tensor wrapper, backward() dispatch |
| Steps | 1. Implement Tensor class wrapping np.ndarray with grad, grad_fn, requires_grad; 2. Implement Function base class with forward, backward, save_for_backward; 3. Implement add, mul, matmul, relu, softmax, log_softmax, cross_entropy as Function subclasses; 4. Implement backward() via topological sort + reverse traversal; 5. Implement no_grad() context manager; 6. Run gradcheck style numerical verification vs PyTorch |
| Stack | Python 3.10, NumPy 1.26, PyTorch (for verification only) |
| Datasets | Synthetic: random MLPs on MNIST features (loaded from sklearn) |
| Output | minigrad/ package; all 15 gradient checks pass within 1e-5 relative error |
| How to Test | python test_lab.py — runs gradcheck on all Function subclasses; trains a 2-layer MLP for 100 steps and validates loss decreases monotonically |
| Talking Points | Why topological sort? What breaks with cycles? How does PyTorch's C++ engine differ from this pure Python version? When would you use create_graph=True? |
| Resume Bullet | Implemented reverse-mode AD engine from scratch in Python; passed numerical gradient checks for 15 op types within 1e-5 relative tolerance, demonstrating mastery of PyTorch autograd internals |
| Extensions | Add second-order gradient support; implement vmap style batched AD; add XLA-style HLO emission |
Lab 02 — Custom C++ PyTorch Operator
| Field | Value |
|---|---|
| Goal | Write a custom warp_softmax C++ operator registered via TORCH_LIBRARY, including a CPU kernel (SIMD-friendly), a CUDA kernel (one warp per row), and metadata for torch.compile compatibility |
| Concepts | TORCH_LIBRARY, TORCH_LIBRARY_IMPL, dispatch keys, at::Tensor, structured kernels, OpCheck, pybind11, CMake build system, torch.compile compatibility rules |
| Steps | 1. Define operator schema in TORCH_LIBRARY; 2. Implement CPU kernel with vectorized exp+sum; 3. Implement CUDA kernel (32 threads/warp, __shfl_xor_sync reduction); 4. Register both impls via TORCH_LIBRARY_IMPL; 5. Write pybind11 PYBIND11_MODULE wrapper; 6. Add Abstract (Meta) kernel for torch.compile shape propagation; 7. Run torch.library.opcheck(); 8. Benchmark vs torch.nn.functional.softmax |
| Stack | C++17, CUDA 12.x, CMake 3.20+, PyTorch 2.3+ C++ extension API, torch.utils.cpp_extension |
| Datasets | Synthetic: torch.randn(batch, seq_len, vocab_size) tensors |
| Output | warp_softmax.so loadable via torch.ops.load_library(); 10–20% latency improvement vs baseline on sequence length ≥ 4096 |
| How to Test | pytest test_lab.py — tests: (1) output matches F.softmax within 1e-6, (2) OpCheck passes all dispatch keys, (3) works under torch.compile, (4) gradients match via gradcheck |
| Talking Points | What is a dispatch key and why does order matter? What is the Meta kernel and why does torch.compile require it? How does TORCH_LIBRARY_IMPL(..., CUDA) interact with device placement? |
| Resume Bullet | Wrote production-ready custom CUDA softmax op in C++/CUDA registered via TORCH_LIBRARY; passed opcheck on all dispatch keys and achieved 1.18x speedup over F.softmax at vocab=32K |
| Extensions | Add torch.amp (AMP) support; add FP8 kernel path; add torch.vmap batch support via vmap dispatch key |
Lab 03 — TF tf.function Tracing & Graph Analysis
| Field | Value |
|---|---|
| Goal | Build a tool that (a) traces a TF2 model under tf.function, (b) visualizes the concrete FuncGraph node-by-node, (c) identifies retracing triggers, and (d) exports to TFLite and validates accuracy delta |
| Concepts | tf.function, AutoGraph, ConcreteFunction, FuncGraph, tracing vs execution, Python side effects, input_signature, reduce_retracing, SavedModel, TFLite conversion |
| Steps | 1. Implement a trace_and_inspect(fn, inputs) utility that captures the FuncGraph, counts nodes by op type, and prints a summary; 2. Demonstrate 3 retracing trigger scenarios (new shape, new Python value, new closure); 3. Build a custom BERT-tiny model with tf.function on call(); 4. Use tf.saved_model.save then reload and verify outputs match; 5. Convert to TFLite (dynamic range quant) and measure accuracy delta on MNLI dev set; 6. Annotate the FuncGraph with compute cost estimates |
| Stack | TensorFlow 2.15, TFLite 2.15, Python 3.10, tensorflow_datasets |
| Datasets | MNLI dev set (from tensorflow_datasets); BERT-tiny weights from HuggingFace |
| Output | graph_inspector.py tool; FuncGraph visualization (JSON + text); MNLI accuracy: FP32 baseline vs TFLite dynamic-range within 0.5% |
| How to Test | python test_lab.py — checks: retracing count matches expected, FuncGraph node count is deterministic, TFLite output matches TF output within 1e-4 for 100 test samples |
| Talking Points | When does tf.function retrace vs reuse? What are tf.Variable constraints inside traced code? How does AutoGraph transform Python if/for? When is TFLite conversion not lossless? |
| Resume Bullet | Built TF2 graph analysis tool that identifies retracing triggers and validates TFLite conversion accuracy; demonstrated <0.3% MNLI accuracy degradation under dynamic-range quantization |
| Extensions | Add MLIR bridge analysis (tf.experimental.mlir_bridge); profile XLA compilation cost; instrument with TF Profiler |
Deliverables Checklist
-
minigradpackage with all 15 gradient checks passing -
warp_softmax.sowith OpCheck passing and benchmarks documented - TF graph inspector tool with FuncGraph visualization output
-
All
test_lab.pysuites pass:pytest phase-01-*/lab-0*/test_lab.py -
Personal notes in each
HITCHHIKERS-GUIDE.mdsection annotated with your insights
Interview Relevance
- "Explain how PyTorch's
backward()traverses the computation graph." (you can draw the DAG, explain topological sort, explain AccumulateGrad nodes) - "What is a dispatch key in PyTorch? How does
torch.compileuse it?" (you can explain Meta kernels, Functionalize key, and why OpCheck tests all of them) - "When does
tf.functionretrace?" (you can enumerate all 5 retracing scenarios from memory after Lab 03) - "How would you debug a NaN appearing after quantization in only graph mode?" (you have the mental model to distinguish Python-side bugs from kernel-side bugs)
- "Write a custom backward for a fused attention operation." (Lab 01 gives you the foundations to extend on a whiteboard)
Warmup Guide — PyTorch & TensorFlow Framework Internals
Who this is for: Someone who knows Python basics and wants to reach principal ML engineer level — able to contribute to PyTorch, write CUDA kernels, debug framework internals, and reason about TensorFlow's compilation model. No prior ML, C++, or GPU experience assumed. Nothing is skipped. Every concept is built from the ground up.
Table of Contents
Part I — The Mathematical Substrate
- Chapter 1: Tensors — From Linear Algebra to Memory
- Chapter 2: Derivatives and the Chain Rule — The Mathematics of Learning
- Chapter 3: Jacobians — Why Backpropagation Works at Scale
- Chapter 4: Automatic Differentiation — Two Modes, One Winner
Part II — How Computers Execute Code
- Chapter 5: CPython — How Python Actually Runs
- Chapter 6: NumPy — Arrays, Memory Layout, and Vectorization
- Chapter 7: C++ and pybind11 — The Bridge to Performance
Part III — PyTorch Architecture
- Chapter 8: ATen, c10, and libtorch — The C++ Foundation
- Chapter 9: Tensors in PyTorch — Storage, Strides, and Memory Management
- Chapter 10: The Dispatcher — PyTorch's Universal Routing System
- Chapter 11: The Autograd Engine — Building and Traversing the Backward Graph
- Chapter 12: torch.autograd.Function — Custom Differentiable Operations
Part IV — Graph Capture and Compilation
- Chapter 13: torch.fx — Intermediate Representation and Graph Transformation
- Chapter 14: torch.compile — The Full Compilation Stack
- Chapter 15: GPU Architecture — From Silicon to CUDA Kernels
- Chapter 16: Custom C++ Operators — TORCH_LIBRARY
Part V — TensorFlow
- Chapter 17: TF1 to TF2 — Two Execution Philosophies
- Chapter 18: tf.function — AutoGraph, Tracing, and ConcreteFunctions
- Chapter 19: GradientTape, XLA, and MLIR
Part VI — Numerical Precision
- Chapter 20: IEEE 754 — How Computers Store Numbers
- Chapter 21: FP16, BF16, and Mixed Precision Training
Part VII — Synthesis and Practice
- Chapter 22: How Everything Connects — The Principal Engineer's Map
- Chapter 23: Lab Guidance — Building minigrad and warp_softmax
- Chapter 24: The Interview Gauntlet — Complete Answers
Part I — The Mathematical Substrate
Chapter 1: Tensors — From Linear Algebra to Memory
1.1 What Is a Tensor, Mathematically?
The word "tensor" comes from Latin tendere — to stretch. In mathematics, a tensor is a generalization of scalars, vectors, and matrices to any number of dimensions.
Start with the simplest case:
- A scalar is a single number:
5.0. It has no dimensions — rank 0. - A vector is an ordered list of numbers:
[1.0, 2.0, 3.0]. It has one dimension — rank 1. A vector of length 3 lives in 3D space. Each number describes magnitude along one axis. - A matrix is a 2D grid of numbers, indexed by row and column. Shape
(3, 4)means 3 rows and 4 columns — rank 2. - A rank-3 tensor is a 3D array. Shape
(batch, height, width)for a batch of grayscale images. You can picture it as a stack of matrices. - A rank-4 tensor extends to
(batch, channels, height, width)for RGB images.
The generalization: a rank-\(n\) tensor is indexed by \(n\) integers and holds a number at each index position.
Scalar: 5.0 shape ()
Vector: [1, 2, 3] shape (3,)
Matrix: [[1, 2], [3, 4], [5, 6]] shape (3, 2)
Rank-3: [[[1,2],[3,4]], [[5,6],[7,8]]] shape (2, 2, 2)
Why tensors matter for ML: A neural network's weight matrix is a rank-2 tensor. A batch of images is rank-4. The activations inside a transformer attention layer are rank-3 (batch × sequence_length × d_model). Everything in deep learning lives in tensor space. The framework's entire job is to manipulate tensors efficiently.
1.2 Tensors as Multi-Linear Maps (The Deep Math)
In formal mathematics, a tensor is a multi-linear map — a function that takes multiple vectors as inputs and produces a number, and is linear in each of its inputs separately.
A matrix M of shape (m, n) is a rank-2 tensor. It maps a vector v of length n to a vector Mv of length m. The mapping is linear: M(αv + βw) = αMv + βMw.
Why does this matter for engineers? Because the transpose rule for backpropagation — the fact that the gradient of y = Ax with respect to x is A^T grad_y — comes directly from this multi-linear structure. If you know what kind of object a tensor is mathematically, you can derive its gradient mechanically instead of looking it up.
1.3 Tensors in Memory: The Physical Reality
When you write torch.tensor([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]), what exists in your computer's RAM?
Physical memory (a flat array of bytes):
[1.0, 2.0, 3.0, 4.0, 5.0, 6.0] ← 6 float32s = 24 bytes, contiguous
The tensor's 2D shape is an illusion maintained by metadata:
| Metadata field | Value | Meaning |
|---|---|---|
shape | (2, 3) | 2 rows, 3 columns |
dtype | float32 | each element is 4 bytes |
strides | (3, 1) | to move 1 row: skip 3 elements; to move 1 col: skip 1 element |
storage_offset | 0 | data starts at byte 0 of the storage |
device | cpu | memory lives in RAM (not GPU VRAM) |
The strides are the key. To access element at row r, column c, the index into the flat memory is:
flat_index = storage_offset + r × strides[0] + c × strides[1]
= 0 + r × 3 + c × 1
This formula works for any shape and any strides. Operations like .T (transpose), .reshape(), and slicing x[1:, ::2] only change the metadata — they do not copy memory. Two tensors can point to the same physical bytes with different shapes and strides.
x = torch.tensor([[1, 2, 3], [4, 5, 6]]) # shape (2, 3), strides (3, 1)
y = x.T # shape (3, 2), strides (1, 3) — no copy
z = x[::2, :] # shape (1, 3), strides (6, 1) — no copy
Consequence you must internalize: when you call x.contiguous(), PyTorch checks if the strides match the canonical row-major (C-contiguous) layout where each row is stored consecutively. If they don't — because you transposed or sliced — PyTorch allocates a new buffer and copies data into the canonical layout. This is a hidden allocation cost that appears in profiles as unexpected memory spikes.
1.4 The Storage Object
The physical memory block is owned by a Storage object (in C++: at::StorageImpl). Multiple tensors can share the same Storage with different offsets/strides.
x = torch.arange(12).reshape(3, 4)
y = x[1, :] # row 1 — shares storage, offset = 4 * sizeof(int) = 16 bytes
# Same underlying memory:
print(x.storage().data_ptr() == y.storage().data_ptr()) # True
print(y.storage_offset()) # 4 (4 elements offset into shared storage)
This sharing is why in-place operations are dangerous: modifying y modifies x because they share a Storage.
Chapter 2: Derivatives and the Chain Rule — The Mathematics of Learning
2.1 What Is a Derivative?
A derivative measures rate of change. For a function \(f(x)\), the derivative at a point \(x_0\) is:
\[ f'(x_0) = \lim_{h \to 0} \frac{f(x_0 + h) - f(x_0)}{h} \]
In plain English: if you nudge the input by a tiny amount \(h\), the derivative tells you how much the output changes per unit of nudge. If \(f'(x_0) = 6\), then nudging \(x\) by 0.001 changes \(f\) by approximately 0.006.
Geometrically: the derivative is the slope of the tangent line to the curve \(f(x)\) at the point \(x_0\).
For ML: the derivative of the loss with respect to a weight tells you — if I increase this weight slightly, does the loss go up or down, and by how much? This is exactly the information gradient descent needs.
2.2 Partial Derivatives — Functions of Many Variables
A neural network loss depends on millions of weights simultaneously. For a function \(f(x, y, z, \ldots)\), the partial derivative with respect to \(x\) is written \(\frac{\partial f}{\partial x}\) and measures the rate of change of \(f\) when only \(x\) changes, holding all other inputs fixed.
Example: \(f(x, y) = x^2 y + 3y\)
\[ \frac{\partial f}{\partial x} = 2xy \qquad \frac{\partial f}{\partial y} = x^2 + 3 \]
The gradient of \(f\) is the vector of all partial derivatives:
\[ \nabla f = \left[\frac{\partial f}{\partial x},\ \frac{\partial f}{\partial y}\right] \]
For a neural network with weights \(W_1, W_2, b_1, b_2, \ldots\), the gradient of the loss is the vector:
\[ \nabla_\theta \mathcal{L} = \left[\frac{\partial \mathcal{L}}{\partial W_1},\ \frac{\partial \mathcal{L}}{\partial W_2},\ \frac{\partial \mathcal{L}}{\partial b_1},\ \ldots\right] \]
Each entry tells you how sensitive the loss is to that weight. Gradient descent subtracts a small multiple of this gradient from the weights:
\[ \theta \leftarrow \theta - \eta \nabla_\theta \mathcal{L} \]
where \(\eta\) (eta) is the learning rate. This moves the weights in the direction that reduces loss.
2.3 The Chain Rule — The Engine of Everything
The chain rule is a rule for differentiating composed functions — functions of functions. This is exactly what a neural network is: a composition of layers.
Single-variable form: if \(z = f(y)\) and \(y = g(x)\), then:
\[ \frac{dz}{dx} = \frac{dz}{dy} \cdot \frac{dy}{dx} \]
Read this as: "how much does \(x\) affect \(z\)?" equals "how much does \(y\) affect \(z\)?" times "how much does \(x\) affect \(y\)?".
Derivation from first principles: Using the limit definition:
\[ \frac{dz}{dx} = \lim_{h \to 0} \frac{f(g(x+h)) - f(g(x))}{h} \]
Let \(\Delta y = g(x+h) - g(x)\). When \(h \to 0\), \(\Delta y \to 0\) as well (assuming \(g\) is continuous). So:
\[ = \lim_{\Delta y \to 0} \frac{f(g(x) + \Delta y) - f(g(x))}{\Delta y} \cdot \lim_{h \to 0} \frac{g(x+h) - g(x)}{h} = f'(g(x)) \cdot g'(x) \]
Multi-variable form: if \(z = f(x, y)\) where \(x = g(t)\) and \(y = h(t)\), then:
\[ \frac{dz}{dt} = \frac{\partial f}{\partial x} \cdot \frac{dx}{dt} + \frac{\partial f}{\partial y} \cdot \frac{dy}{dt} \]
This "sum over all paths" form is exactly how autograd handles a variable like a that is used in multiple downstream operations — it sums up the gradient contributions from each usage.
Concrete example — trace d = (a × b) + a:
a = 2.0, b = 3.0
c = a × b = 6.0 (∂c/∂a = b = 3, ∂c/∂b = a = 2)
d = c + a = 8.0 (∂d/∂c = 1, ∂d/∂a = 1)
Chain rule to find ∂d/∂a (two paths: direct through add, and indirect through mul):
∂d/∂a = ∂d/∂c × ∂c/∂a + ∂d/∂a (direct)
= 1 × 3 + 1
= 4
Verify: d = (a × b) + a = ab + a, so ∂d/∂a = b + 1 = 3 + 1 = 4. ✓
This sum-over-paths structure is not a coincidence — it is exactly what the backward pass computes when it accumulates gradients into leaf tensors.
Chapter 3: Jacobians — Why Backpropagation Works at Scale
3.1 The Jacobian Matrix
When a function maps a vector to a vector — \(\mathbf{y} = f(\mathbf{x})\) where \(\mathbf{x} \in \mathbb{R}^n\) and \(\mathbf{y} \in \mathbb{R}^m\) — the derivative is no longer a single number. It is a matrix called the Jacobian:
\[ J = \frac{\partial \mathbf{y}}{\partial \mathbf{x}} = \begin{bmatrix} \frac{\partial y_1}{\partial x_1} & \cdots & \frac{\partial y_1}{\partial x_n} \ \vdots & \ddots & \vdots \ \frac{\partial y_m}{\partial x_1} & \cdots & \frac{\partial y_m}{\partial x_n} \end{bmatrix} \]
Entry \(J_{ij} = \frac{\partial y_i}{\partial x_j}\) — how much output \(i\) changes when input \(j\) changes.
Example: linear layer \(\mathbf{y} = W\mathbf{x}\) where \(W\) is \((m \times n)\). Then \(y_i = \sum_j W_{ij} x_j\), so \(\frac{\partial y_i}{\partial x_j} = W_{ij}\). The Jacobian is exactly \(W\). The gradient of \(\mathbf{y}\) with respect to \(\mathbf{x}\) IS the weight matrix.
3.2 Why Backpropagation Is a Sequence of Jacobian-Vector Products
The chain rule in matrix form: given compostion \(f \circ g\) mapping \(\mathbf{x} \to \mathbf{y} \to \mathbf{z}\):
\[ \frac{\partial \mathbf{z}}{\partial \mathbf{x}} = \frac{\partial \mathbf{z}}{\partial \mathbf{y}} \cdot \frac{\partial \mathbf{y}}{\partial \mathbf{x}} = J_f \cdot J_g \]
For a neural network with loss \(\mathcal{L}\) (a scalar), backprop computes:
\[ \frac{\partial \mathcal{L}}{\partial \mathbf{x}} = \left(\frac{\partial \mathcal{L}}{\partial \mathbf{y}}\right)^T J_g \]
This is a vector-Jacobian product (VJP). The vector is the upstream gradient (already computed); the Jacobian is the local derivative of this operation. We never form the full Jacobian matrix — we compute the product directly.
For a Linear layer y = Wx:
- The Jacobian with respect to
xisW - The VJP (upstream gradient
gtimes Jacobian) isg @ Wor equivalentlyW.T @ g
This is why grad_x = grad_y @ W.T in every linear layer's backward pass. It's not magic — it's the VJP of the Jacobian W.
3.3 Why This Justifies Reverse Mode
Forward mode computes Jacobian-vector products — how outputs change per input direction. To get gradients of a scalar loss w.r.t. all inputs, you'd need one forward pass per input dimension.
Reverse mode computes vector-Jacobian products — how the scalar loss flows back through each operation. One reverse pass gives you gradients w.r.t. all inputs simultaneously.
For neural networks: millions of inputs (weights), one output (loss). Reverse mode is \(O(\text{forward pass cost})\) regardless of how many parameters you have. Forward mode would be \(O(\text{parameters} \times \text{forward pass cost})\) — completely infeasible for 7-billion-parameter models.
Chapter 4: Automatic Differentiation — Two Modes, One Winner
4.1 What Is Automatic Differentiation?
Automatic differentiation (autodiff) is not symbolic differentiation (like a CAS doing algebra) and not numerical differentiation (finite differences). It is the mechanical application of the chain rule to the actual program that computes the function, tracking derivatives alongside values.
Three approaches exist:
| Approach | How it works | Accuracy | Speed |
|---|---|---|---|
| Numerical | (f(x+h) - f(x-h)) / 2h | Approximate (truncation error) | O(n) passes for n inputs |
| Symbolic | Derive the derivative expression algebraically | Exact but can "expression swell" | Depends on expression complexity |
| Automatic | Apply chain rule to each primitive op mechanically | Exact (machine precision) | O(1) passes for reverse mode |
PyTorch's autograd IS automatic differentiation. It achieves exact derivatives (up to floating-point rounding) in O(forward pass cost) time.
4.2 The Execution Tape
Reverse-mode autodiff works by recording a tape (also called the Wengert list) of all operations executed during the forward pass. The tape stores:
- The operation performed (add, mul, matmul, relu, …)
- The inputs that were consumed
- Any saved intermediate values needed for the backward
After the forward pass, the tape is traversed in reverse to compute gradients.
Forward (recording):
a = 2.0 (leaf)
b = 3.0 (leaf)
c = a * b → tape: [MUL, inputs=(a,b), saved=(a=2.0, b=3.0)]
d = c + a → tape: [ADD, inputs=(c,a)]
loss = d
Backward (replay in reverse):
d.backward(grad=1.0):
ADD.backward(grad=1.0) → sends 1.0 to c, 1.0 to a
MUL.backward(grad=1.0) → grad_a += 1.0*b=3.0, grad_b = 1.0*a=2.0
a.grad = 1.0 (from ADD) + 3.0 (from MUL) = 4.0
b.grad = 2.0
PyTorch does not literally have a "tape list" — it uses a DAG of Function objects attached to tensors. But the concept is identical.
4.3 Forward Mode vs Reverse Mode — The Full Story
Forward mode propagates derivatives from inputs to outputs alongside the forward computation. Each variable carries a "tangent" value representing its derivative w.r.t. a chosen input direction.
For f: R^n → R^m with n inputs and m outputs:
- Forward mode costs O(n) × forward pass — one pass per input
- Efficient when n < m (few inputs, many outputs)
Reverse mode propagates gradients from the output back to inputs.
- Reverse mode costs O(m) × forward pass — one pass per output
- Efficient when m < n (many inputs, one output) — exactly the ML case
Neural network training: n = millions of parameters, m = 1 (the loss scalar). Reverse mode is literally a million times more efficient than forward mode for this setting. This is the entire reason deep learning is tractable.
JAX supports both modes (jax.jvp for forward, jax.vjp for reverse) as first-class citizens. PyTorch only exposes reverse mode through autograd but supports forward mode via torch.autograd.functional.jvp.
Part II — How Computers Execute Code
Chapter 5: CPython — How Python Actually Runs
5.1 Python Is Not Executed Directly
When you run python script.py, Python does NOT interpret the text of your file line by line. It compiles it first.
Step 1 — Compilation to bytecode: Python's parser turns your source code into an AST (Abstract Syntax Tree), then the compiler turns the AST into bytecode — a sequence of simple, stack-based instructions. This bytecode is stored in .pyc files in __pycache__/.
def add(a, b):
return a + b
Compiled bytecode (shown via dis module):
LOAD_FAST 0 (a) # push local variable 'a' onto the stack
LOAD_FAST 1 (b) # push local variable 'b' onto the stack
BINARY_ADD # pop two values, push their sum
RETURN_VALUE # pop and return the top of the stack
Step 2 — The CPython interpreter loop: CPython (the standard Python implementation, written in C) executes bytecode in a giant C while loop called ceval.c. For each bytecode instruction, it dispatches to a C function that performs the operation.
The interpreter loop is why Python is "slow" — for every a + b in your code, CPython:
- Fetches the
BINARY_ADDinstruction - Pops two
PyObject*pointers from the stack - Looks up the
__add__method on the left object - Calls through multiple C function layers
- Allocates a new Python object for the result
- Pushes the pointer back onto the stack
This overhead is ~100-1000× more expensive than the equivalent C code doing the same addition.
5.2 Why This Matters for ML Frameworks
ML training loops repeat billions of simple arithmetic operations. If each operation paid the CPython interpreter overhead, training would be impossibly slow. The solution: batch operations into kernels.
When you write y = torch.matmul(a, b):
- CPython executes ONE bytecode instruction (
CALL_FUNCTION) - This calls a C++ function registered via pybind11
- The C++ function dispatches to a highly optimized BLAS routine (Intel MKL or cuBLAS)
- The kernel runs billions of multiplications and additions without returning to CPython
- A single Python object (the output tensor) is returned
The Python layer is thin glue. The heavy lifting is in C++/CUDA.
5.3 The GIL — Global Interpreter Lock
CPython has a fundamental limitation called the GIL (Global Interpreter Lock). Only one Python thread can execute Python bytecode at a time. The GIL is a mutex that prevents concurrent Python execution.
Why does this exist? CPython's memory management (reference counting) is not thread-safe. Making it thread-safe would require fine-grained locks on every object, which would be slow. The GIL is a coarse lock that serializes all Python execution.
Impact on ML: Python-level parallelism doesn't help. But ML frameworks work around this by releasing the GIL during C++ computation:
// PyTorch's pattern for long-running C++ calls:
{
pybind11::gil_scoped_release release; // release the GIL
// This C++ code runs without holding Python's lock.
// Other Python threads can run while we're computing.
result = do_expensive_computation();
}
// GIL is re-acquired here automatically
During a CUDA kernel launch or a matmul call, Python threads are free to run (e.g., data loading threads) because the GIL is released. This is the mechanism behind PyTorch's DataLoader with num_workers > 0.
5.4 Why TorchDynamo Needs to Understand Bytecode
torch.compile uses TorchDynamo to capture computation graphs. Dynamo works by installing a frame evaluation callback in CPython — a hook that intercepts bytecode execution before it runs.
When Python is about to execute a @torch.compile-decorated function, Dynamo's callback fires. Dynamo reads the raw bytecode instructions (LOAD_FAST, BINARY_MULTIPLY, CALL_FUNCTION, etc.) and converts them into a graph of tensor operations. To do this, it must understand:
- Which bytecode instructions produce tensor values
- Which instructions are Python-only (don't involve tensors)
- Where control flow (jumps, conditionals) happens
This is why torch.compile is more powerful than torch.fx.symbolic_trace — it operates at the bytecode level where it can see everything, not at the Python function level where it only sees what you call explicitly.
Chapter 6: NumPy — Arrays, Memory Layout, and Vectorization
6.1 Why NumPy Exists
Python lists are arrays of Python objects. A list of 1000 floats stores 1000 PyObject* pointers, each pointing to a heap-allocated Python float. The floats are scattered in memory. Iterating over them requires following 1000 pointer indirections.
NumPy arrays store raw values contiguously in C-allocated memory — no Python objects, no pointers. A NumPy array of 1000 float64s is exactly 8000 bytes in one contiguous block. Accessing element 500 is a single memory read at offset 500 × 8 = 4000 bytes. No pointer indirection.
This contiguity is what enables vectorization: modern CPUs have SIMD (Single Instruction, Multiple Data) instructions (SSE, AVX, AVX-512) that operate on 4, 8, or 16 floats simultaneously. A well-optimized loop over a contiguous float array uses these instructions automatically.
6.2 Broadcasting — The Rules You Must Internalize
Broadcasting is NumPy's mechanism for applying operations between arrays of different shapes without copying. The rules:
- If arrays have different numbers of dimensions, the smaller is padded with 1s on the left
- Two dimensions are compatible if they are equal OR one of them is 1
- The output shape is the element-wise maximum of the input shapes
a shape: ( 3, 1)
b shape: ( 4, 3, 5)
───────────────────
( 4, 3, 5) ← output: a is repeated along dim 0 and 1, b along dim 2
Why this matters for autograd: when you compute a + b and a was broadcast, the gradient of the loss w.r.t. a must be summed over the broadcast dimensions — because the same a values contributed to multiple output positions. This is _unbroadcast in the Lab 01 solution:
def _unbroadcast(grad, target_shape):
# Sum over dimensions that were broadcast during forward
...
Forgetting to unbroadcast is one of the most common bugs in custom autograd implementations.
6.3 Vectorization vs Python Loops
# Slow: Python loop, one operation per iteration
result = []
for i in range(len(a)):
result.append(a[i] * 2.0)
# Fast: vectorized, operates on whole array at once
result = a * 2.0 # single NumPy/PyTorch call → one C kernel
The fast version:
- Calls into C code
- Loops in C (100-200× faster than Python loops)
- May use SIMD to process 8 floats per CPU clock cycle
- Has no Python object allocation per element
For 1 million elements: the Python loop might take 500ms; the vectorized version might take 0.5ms — 1000× faster.
Chapter 7: C++ and pybind11 — The Bridge to Performance
7.1 Why PyTorch Is Written in C++
C++ gives two things Python cannot: predictable memory management and zero-overhead abstractions.
In C++, you control when memory is allocated and freed. You can allocate a tensor buffer once and reuse it without the garbage collector deciding to reclaim it at an inopportune moment. In deep learning, where a single forward pass may allocate and free gigabytes of GPU memory, this control is critical.
C++ also compiles to native machine code. The compiler can inline function calls, unroll loops, and use SIMD instructions automatically. Python cannot do any of this because it must interpret bytecode at runtime.
PyTorch's core (libtorch) is entirely C++. The Python interface is generated via pybind11.
7.2 What pybind11 Does
pybind11 is a header-only C++ library that exposes C++ classes and functions to Python. It bridges the two type systems:
// C++ function
at::Tensor my_relu(const at::Tensor& input) {
return input.clamp(0);
}
// pybind11 module definition
PYBIND11_MODULE(my_module, m) {
m.def("relu", &my_relu,
py::arg("input"),
"Apply ReLU activation");
}
After building this as a .so file, Python can import and call it:
import my_module
y = my_module.relu(x) # calls C++ function with Python tensor
pybind11 handles the conversion between Python objects (PyObject*) and C++ objects (at::Tensor) transparently. When you pass a PyTorch tensor from Python to the C++ function, pybind11 unwraps the Python object to get the underlying at::Tensor C++ object.
7.3 RAII — How C++ Manages Memory
C++ does not have garbage collection. Instead, it uses RAII (Resource Acquisition Is Initialization): resources (memory, file handles, GPU buffers) are tied to C++ object lifetimes. When an object goes out of scope, its destructor automatically releases resources.
{
at::Tensor t = torch::randn({1024, 1024}); // GPU memory allocated
// ... use t ...
} // t goes out of scope → destructor runs → GPU memory freed (returned to pool)
This deterministic cleanup is why PyTorch can manage GPU memory so precisely. Python's garbage collector is non-deterministic — you don't know when del tensor will actually free memory. C++ destructors fire immediately when the object leaves scope.
Part III — PyTorch Architecture
Chapter 8: ATen, c10, and libtorch — The C++ Foundation
8.1 The Library Layers
PyTorch's C++ codebase is structured as three layers:
libtorch (the full C++ distribution)
├── ATen (A Tensor Library) — tensor operations
└── c10 (Core 10 / Caffe2 + Torch) — foundational types
├── Dispatcher
├── OperatorRegistry
├── IValue (universal value type)
└── Storage, Device, Dtype
c10 is the foundation. It provides:
c10::Dispatcher— the universal routing system (Chapter 10)c10::IValue— a type-erased container that holds any value (tensor, int, string, list, dict) — used to pass arguments through the dispatch systemc10::Device—cpu:0,cuda:0,cuda:1,meta, etc.c10::ScalarType—float32,float16,int8, etc.c10::Storage/c10::StorageImpl— the physical memory block (Chapter 9)
ATen (A Tensor Library) sits on top of c10 and provides:
at::Tensor— the C++ tensor class (the actual object Python'storch.Tensorwraps)- ~1600 tensor operations defined in the
aten::namespace (aten::add,aten::mm,aten::relu, etc.) - The schema system — each operation has a typed schema string used by the dispatcher
libtorch is the distributable package containing compiled ATen + c10 + autograd, exposing a C++ API (torch::Tensor, torch::nn::Module, etc.) that mirrors PyTorch's Python API.
8.2 The aten:: Namespace — Where Operations Live
Every PyTorch operation ultimately corresponds to a function in the aten:: namespace. When you call torch.relu(x) in Python:
- Python's
torch.reluis a Python wrapper generated from the ATen operator registry - It calls
at::relu(x)in C++ at::reluis registered with the dispatcher under the keyaten::relu- The dispatcher selects the correct implementation based on the tensor's dispatch keys
You can see the full schema of any operation:
print(torch.ops.aten.relu)
# aten::relu(Tensor self) -> Tensor
The schema string is not just documentation — it is parsed by the dispatcher at registration time to understand argument types, mutability, and aliasing. This is how PyTorch's compiler can reason about operations without running them.
Chapter 9: Tensors in PyTorch — Storage, Strides, and Memory Management
9.1 The C++ Object Behind torch.Tensor
Python's torch.Tensor is a thin Python wrapper around at::Tensor (C++). The C++ object contains:
struct TensorImpl {
c10::Storage storage_; // the physical memory block
int64_t storage_offset_; // offset into storage in elements
IntArrayRef sizes_; // shape, e.g., [3, 4]
IntArrayRef strides_; // strides, e.g., [4, 1]
c10::ScalarType dtype_; // float32, int8, etc.
c10::Device device_; // cpu, cuda:0, meta
AutogradMeta* autograd_meta_; // grad_fn, requires_grad, etc. (null if not needed)
DispatchKeySet key_set_; // bit-field of active dispatch keys
};
The DispatchKeySet is a 64-bit integer where each bit represents an active dispatch key. When the dispatcher receives an operation, it inspects the key_set_ of all input tensors (bitwise OR) to find the highest-priority active key.
9.2 The Caching Allocator — Why GPU Memory Isn't Really Freed
Calling cudaMalloc (NVIDIA's API to allocate GPU memory) is expensive: it may require the GPU driver to find a free region, update page tables, and sometimes synchronize with the GPU. A single cudaMalloc can take hundreds of microseconds — orders of magnitude longer than a fast CUDA kernel.
PyTorch's caching allocator (c10/cuda/CUDACachingAllocator.cpp) avoids this by maintaining a pool of already-allocated GPU memory:
When you create a CUDA tensor:
1. Request size S from the allocator
2. Allocator checks its free list for a block ≥ S
3. If found: return that block (no cudaMalloc call — ~1μs)
4. If not found: call cudaMalloc to expand the pool (~200μs)
When the tensor is deallocated:
1. Reference count drops to zero
2. Destructor runs: marks block as "free" in the allocator's pool
3. Does NOT call cudaFree
4. The memory stays in the pool for future allocations
Why torch.cuda.empty_cache() exists: it flushes the pool back to the OS via cudaFree. You'd call this before allocating a very large model to reclaim fragmented small blocks. Under normal operation, never call it — the caching is intentional.
The consequence you'll see in profiling: CUDA memory usage grows until it stabilizes. The allocator is holding memory it has already allocated to avoid future cudaMalloc overhead. This is not a leak — it's intentional pooling.
Chapter 10: The Dispatcher — PyTorch's Universal Routing System
10.1 Why the Dispatcher Exists
Before the dispatcher, PyTorch had a simpler design: each tensor operation had a set of if/else branches choosing the implementation based on tensor type:
// Old approach (simplified pseudocode):
Tensor relu(const Tensor& self) {
if (self.is_cuda()) {
return relu_cuda(self);
} else if (self.is_cpu()) {
return relu_cpu(self);
} else {
throw std::runtime_error("Unknown device");
}
}
This broke every time someone wanted to add:
- A new device (ROCm GPU, XLA, NPU)
- A new feature (autograd, quantization, AMP)
- A new transformation (functorialize mutations, meta tensors)
Each addition required modifying every operation. With 1600+ operations, this was unmaintainable.
The dispatcher solves this with a table-driven routing system where:
- Operations are registered once with a schema
- Implementations are registered independently for each (operation, dispatch_key) pair
- The dispatcher selects the implementation at runtime without any
if/elsein the operation itself
10.2 The DispatchKey Enum
A DispatchKey is an integer in an enum. PyTorch has ~50 dispatch keys. The dispatcher maintains a table of size (num_operators × num_keys) mapping each pair to a function pointer.
The most important keys in priority order:
Highest priority:
Functionalize — converts in-place/aliasing ops to pure functional form.
TorchScript and torch.compile require functional semantics.
Without this, tracking aliasing through graph transformations
is intractable.
InferenceMode — completely disables gradient tracking.
Faster than no_grad for inference-only code.
AutogradMeta — handles FakeTensors (tensors with only shape/dtype, no data).
Used during torch.compile's shape analysis phase.
Autograd — THE gradient-recording layer. Before calling any compute
kernel, wraps the call to record it in the autograd graph.
Sets grad_fn on the output tensor.
AutocastCPU/ — AMP (Automatic Mixed Precision). Intercepts calls and
AutocastCUDA promotes/demotes dtypes (e.g., matmul in float16, norms
in float32) without user code changes.
CUDA — NVIDIA GPU kernels. cuBLAS, cuDNN, custom CUDA code.
CPU — CPU kernels. Intel MKL, Eigen, vectorized C++.
Meta — Shape-only kernels. No data, no computation. Returns
tensors with correct shape/dtype but no storage.
Lowest priority:
BackendSelect — Selects which device backend to use (CPU vs CUDA vs ...)
based on input tensor devices.
10.3 A Complete Dispatch Trace
Call: torch.relu(x) where x is a CUDA tensor with requires_grad=True.
x.key_set_ has bits set for: {CUDA, Autograd}.
Dispatcher checks keys from highest to lowest:
Functionalize? Bit not set. Skip.
InferenceMode? Bit not set. Skip.
AutogradMeta? Bit not set. Skip.
Autograd? Bit IS set → call Autograd implementation.
Autograd implementation of relu:
1. Calls torch::autograd::generated::ReluBackward::apply_with_saved(...)
2. This wraps the call: records that relu ran, creates ReluBackward0 grad_fn
3. Re-dispatches DOWN, excluding the Autograd key:
new_key_set = x.key_set_ & ~DispatchKeySet{Autograd}
→ {CUDA}
4. Calls CUDA implementation of relu
CUDA implementation of relu:
1. Launches CUDA kernel: relu_kernel<<<grid, block>>>(x.data_ptr<float>(), ...)
2. Returns output tensor
Back in Autograd:
output.grad_fn = ReluBackward0 (records: "relu was applied to x")
return output
Total: one Python call → one Autograd interception (sets grad_fn) → one CUDA kernel launch.
10.4 Registering a New Device Backend
This is the extension point for NPU vendors like Qualcomm:
// Register a new PrivateUse1 key for a hypothetical NPU
TORCH_LIBRARY_IMPL(aten, PrivateUse1, m) {
m.impl("relu", &npu_relu_impl);
m.impl("mm", &npu_mm_impl);
// ... etc. for all supported ops
}
A tensor on the NPU device has PrivateUse1 in its key_set_. The dispatcher routes to npu_relu_impl automatically. The Autograd key still sits above it, so gradient recording works automatically unless explicitly disabled.
The ordering matters: PrivateUse1 is below Autograd. If a vendor incorrectly registered above Autograd, gradient recording would be silently bypassed — a catastrophic bug that would only surface during training.
Chapter 11: The Autograd Engine — Building and Traversing the Backward Graph
11.1 The Data Structures
PyTorch's autograd engine (in torch/csrc/autograd/) is built around these C++ classes:
torch::autograd::Node (also called Function): represents one differentiable operation in the graph. Stores:
next_edges_: a list ofEdgeobjects pointing to upstream nodesinput_metadata_: shape/dtype info for gradient validation- Virtual method
apply(variable_list&& inputs)— the backward function
torch::autograd::Edge: a directed edge in the backward graph. Contains:
function: pointer to the upstreamNodeinput_nr: which input of thatNodethis edge corresponds to
torch::autograd::AccumulateGrad: a leaf Node that accumulates gradients into a leaf tensor's .grad field. Every requires_grad=True leaf tensor has one.
AutogradMeta: attached to every tensor that participates in autograd. Contains:
grad_fn_: theNodethat produced this tensor (null for leaves)grad_: accumulated gradient (null until backward is called)requires_grad_: whether this tensor needs gradientsoutput_nr_: which output ofgrad_fn_this tensor is
11.2 DAG Construction During Forward Pass
When the Autograd dispatch key intercepts an operation, it:
- Calls the compute kernel (CPU/CUDA) to get the output values
- Creates a new
Nodesubclass specific to this operation (e.g.,MmBackward0for matmul) - Saves any tensors the backward will need (via
save_for_backward) - Sets
next_edges_to point to thegrad_fn_of each input (orAccumulateGradfor leaves) - Attaches this new
Nodeas thegrad_fn_of the output tensor
After forward pass of:
c = a @ b (where a, b require_grad)
Memory layout:
a → AutogradMeta { grad_fn=null, requires_grad=true }
b → AutogradMeta { grad_fn=null, requires_grad=true }
c → AutogradMeta { grad_fn=MmBackward0 }
MmBackward0 {
next_edges: [AccumulateGrad(a), AccumulateGrad(b)]
saved_tensors: [a, b] ← for computing grad_a = grad_c @ b.T
}
11.3 The Backward Pass: Engine::execute()
c.backward() calls torch::autograd::Engine::execute(). The C++ engine:
- Creates a priority queue (or thread pool) of nodes to process
- Initializes the root node (
c.grad_fn = MmBackward0) withgrad = ones_like(c) - Processes nodes in reverse topological order:
- For each node, calls
node.apply(incoming_gradients)→ returns gradient tuples - For each output gradient, sends it to the corresponding
next_edge.function - When a function receives gradients from ALL its outputs (tracked by a counter), it's ready to run
- For each node, calls
- When
AccumulateGradruns, adds incoming gradient toleaf.grad
The readiness counter is what implements topological ordering. Each node tracks how many downstream outputs still need to send it a gradient. A node runs only when this counter reaches zero — i.e., all downstream contributions have arrived.
Initial state:
MmBackward0.ready_count = 1 (one consumer: the starting loss gradient)
After loss.backward(grad=1.0):
Engine sends 1.0 to MmBackward0
MmBackward0.ready_count decrements to 0 → runs now
MmBackward0.apply([grad_c]):
grad_a = grad_c @ b.T (saved b from forward)
grad_b = a.T @ grad_c (saved a from forward)
Returns [grad_a, grad_b]
Engine sends grad_a to AccumulateGrad(a) → a.grad += grad_a
Engine sends grad_b to AccumulateGrad(b) → b.grad += grad_b
11.4 Why torch.no_grad() Is Not Just "Disabling Gradients"
When you enter torch.no_grad(), PyTorch sets GradMode::is_enabled() to false. The Autograd dispatch key's code checks this flag before creating Function objects:
// In the autograd dispatch implementation:
if (!GradMode::is_enabled()) {
// Skip all autograd recording, call compute kernel directly
return at::AutoDispatchBelowADInplaceOrView::redispatch(...);
}
What is NOT allocated when no_grad() is active:
- No
Nodeobjects (noMmBackward0, etc.) - No
AutogradMetaon output tensors (or stripped-down version) - No saved tensors (the inputs to the backward function)
- No
next_edges_(the DAG edges)
For a large transformer forward pass, this can be gigabytes of saved activations that are simply never allocated. The speedup comes from: (1) no memory allocation for the graph, (2) no reference counting overhead for saved tensors, (3) no function dispatch overhead for the autograd wrapper.
Chapter 12: torch.autograd.Function — Custom Differentiable Operations
12.1 When and Why to Write One
PyTorch knows how to differentiate through all built-in operations. But sometimes you need to:
- Implement an operation that doesn't exist in PyTorch (a custom NPU intrinsic)
- Override the default backward with a more numerically stable or efficient version
- Implement a non-standard gradient (e.g., straight-through estimator for quantization)
torch.autograd.Function lets you inject a custom (forward, backward) pair into the autograd graph as a single opaque node.
12.2 The API, Fully Explained
class MySoftmax(torch.autograd.Function):
@staticmethod
def forward(ctx, x, dim):
# ctx is a FunctionContext object. It is the storage for the backward.
# It is NOT a Python object you create — it's provided by the engine.
#
# RULE: Never store tensors directly as ctx attributes.
# Use ctx.save_for_backward() for tensors.
# Use ctx.dim = dim for non-tensor metadata.
x_max = x.max(dim=dim, keepdim=True).values
x_shift = x - x_max # numerically stable: all values ≤ 0
exp_x = x_shift.exp()
sum_exp = exp_x.sum(dim=dim, keepdim=True)
out = exp_x / sum_exp
ctx.save_for_backward(out) # saves output (not input) for backward
ctx.dim = dim # saves non-tensor metadata as attribute
return out
@staticmethod
def backward(ctx, grad_output):
# grad_output: the gradient of the loss w.r.t. the output of forward()
# shape matches the output of forward()
out, = ctx.saved_tensors # retrieves saved tensors
dim = ctx.dim
# Softmax Jacobian-vector product:
# ∂L/∂x_i = out_i × (∂L/∂y_i - Σ_j (∂L/∂y_j × out_j))
dot = (grad_output * out).sum(dim=dim, keepdim=True)
grad_x = out * (grad_output - dot)
# RULE: return one gradient per argument to forward().
# forward() took (ctx, x, dim). dim is a Python int (non-differentiable).
# Return grad_x for x, and None for dim.
return grad_x, None
# Usage:
result = MySoftmax.apply(x, dim=-1) # Note: .apply(), not direct call
12.3 The Memory Management Rule: Why save_for_backward
If you write ctx.my_tensor = x instead of ctx.save_for_backward(x):
- PyTorch's memory-efficient gradient checkpointing cannot track this tensor
- The version counter check (detecting in-place mutations) is bypassed — you may compute wrong gradients silently if
xis modified in-place after the forward - The tensor is not freed when the backward graph is freed (memory leak)
ctx.save_for_backward registers the tensor with the engine. The engine:
- Tracks the version counter at save time
- Checks the version counter at backward time (raises error if tensor was modified in-place)
- Frees the saved tensors as soon as
backward()processes this node (unlessretain_graph=True)
12.4 The Straight-Through Estimator — A Real Production Use Case
Quantization maps a float value to a small integer (e.g., float32 → int8). The quantize-dequantize operation:
def quantize(x, scale, zero_point):
return torch.clamp(torch.round(x / scale) + zero_point, 0, 255)
The gradient of round() is zero almost everywhere (it's a staircase). This means no gradient flows back through quantized layers — training with quantization would not work.
The Straight-Through Estimator (STE): pretend in the backward pass that quantization didn't happen — pass gradients straight through as if the operation were an identity. This is physically inaccurate but empirically works for training quantized models.
class QuantizeSTE(torch.autograd.Function):
@staticmethod
def forward(ctx, x, scale, zero_point):
# Real quantization in forward
x_int = torch.clamp(torch.round(x / scale) + zero_point, 0, 255)
return (x_int - zero_point) * scale # dequantize
@staticmethod
def backward(ctx, grad_output):
# Straight-through: gradient passes as if forward were identity
return grad_output, None, None
This pattern is used in every QAT (Quantization-Aware Training) framework — AIMET, PyTorch native QAT, and custom NPU training toolchains. You will implement exactly this in Phase 03.
Part IV — Graph Capture and Compilation
Chapter 13: torch.fx — Intermediate Representation and Graph Transformation
13.1 What Is an Intermediate Representation?
In compiler design, an intermediate representation (IR) is a data structure that sits between the source language (Python) and the target (native code). Good IRs are:
- Analyzable: easy to traverse and inspect programmatically
- Transformable: easy to modify (insert, delete, reorder nodes)
- Lowerable: can be compiled/interpreted efficiently
Famous IRs: LLVM IR (used by Clang/Rust), JVM bytecode (used by Java/Kotlin), WebAssembly. torch.fx is PyTorch's ML-specific IR.
13.2 The Problem FX Solves
Before FX, if you wanted to fuse conv2d → batch_norm → relu into one kernel:
- You'd have to modify PyTorch's C++ source code
- Or hook into Python's metaclass system in fragile ways
- There was no clean programmatic way to inspect and modify a model's computation
FX provides a first-class Python object representing the computation — a GraphModule containing a Graph of Node objects. You can write Python code that traverses the graph, finds patterns, and rewrites them.
13.3 How Symbolic Tracing Works — The Proxy Mechanism
torch.fx.symbolic_trace(model) does something clever: it runs the model's forward() function, but instead of passing real tensors, it passes Proxy objects.
A Proxy is a Python object that:
- Behaves like a tensor (has
.shape,.dtype,.device) - Records every operation performed on it instead of executing anything
- Returns a new
Proxyrepresenting the output of each operation
class Proxy:
def __add__(self, other):
# Don't compute! Record that addition happened.
node = self.graph.create_node(
op='call_function',
target=operator.add,
args=(self, other)
)
return Proxy(node, self.graph)
def __matmul__(self, other):
# Same pattern for all operations
node = self.graph.create_node(...)
return Proxy(node, self.graph)
When model.forward(proxy_input) executes:
self.linear(proxy_input)→ recordscall_module[linear]torch.relu(intermediate_proxy)→ recordscall_function[relu]return proxy.sum()→ recordscall_method[sum]
After the function returns, the graph object contains all recorded nodes. FX wraps it in a GraphModule that re-executes the graph using real tensors.
13.4 The Four Node Types and Their Fields
Every node has:
node.op: one ofplaceholder,call_function,call_module,call_method,outputnode.name: a unique string identifiernode.target: what is being called (a function, module name, or method name)node.args: positional arguments (may reference other nodes)node.kwargs: keyword argumentsnode.users: set of downstream nodes that consume this node's output
# After tracing a model:
for node in graph.nodes:
if node.op == 'call_function' and node.target == torch.relu:
# Access predecessors:
for arg in node.args:
if isinstance(arg, Node):
print(f"{arg.name} → relu → {node.name}")
# Access successors:
for user in node.users:
print(f"relu output used by: {user.name}")
13.5 Transforming Graphs: The Pattern-Replace Loop
The core graph transformation pattern:
def insert_quantization_observers(model):
traced = torch.fx.symbolic_trace(model)
# Collect nodes to transform (can't modify graph while iterating)
nodes_to_wrap = []
for node in traced.graph.nodes:
if node.op == 'call_module':
module = traced.get_submodule(node.target)
if isinstance(module, (torch.nn.Conv2d, torch.nn.Linear)):
nodes_to_wrap.append(node)
# Apply transformations
for node in nodes_to_wrap:
with traced.graph.inserting_after(node):
# Insert an observer node after each conv/linear
observer_name = node.name + '_observer'
traced.add_module(observer_name, MinMaxObserver())
new_node = traced.graph.call_module(observer_name, (node,))
# Replace all uses of node with new_node
node.replace_all_uses_with(new_node)
# Move new_node to immediately after node in the graph
new_node.args = (node,)
traced.recompile() # regenerate Python code from modified graph
return traced
This pattern is exactly how AIMET, PyTorch's quantize_fx, and torch.compile's optimization passes work.
13.6 FX's Fundamental Limitations
Limitation 1: Data-dependent control flow is opaque.
def forward(x):
n = x.shape[0] # Python int — OK
if x.sum() > 0: # x.sum() returns a Proxy, > 0 raises TraceError
return x * 2
return x + 1
The Proxy for x.sum() is a symbolic object with no concrete value. Python's if statement calls bool() on it, which raises TraceError: Tried to use FX tracing to trace a dynamic value.
Limitation 2: Shapes are concrete at trace time.
If you trace with x.shape = (4, 128), shape information is baked into the graph. Running on x.shape = (8, 128) may work (if no shapes are used as constants) or silently produce wrong results (if shapes were used in computation).
Why torch.compile avoids these limitations: TorchDynamo operates at the bytecode level. It sees the raw COMPARE_OP bytecode instruction for x.sum() > 0 and handles it differently — it evaluates the condition with the actual tensor (breaking the graph if needed) rather than trying to symbolically trace through it.
Chapter 14: torch.compile — The Full Compilation Stack
14.1 What Problem torch.compile Solves
A pure eager PyTorch model executes one operation at a time:
- Python calls
relu(x)→ dispatch → CUDA kernel launch - Python calls
linear(x)→ dispatch → cuBLAS call - Python calls
layer_norm(x)→ dispatch → another CUDA kernel
Each kernel launch has overhead: Python→C++ dispatch (~1μs), CUDA launch latency (~5μs), GPU memory round-trip for intermediate results. For a transformer layer with 100+ operations, these overheads accumulate.
torch.compile captures a sequence of operations as a static graph and compiles it to optimized native code:
- Fuses
relu + linearinto one kernel that reads input once - Eliminates intermediate tensor allocations between fused ops
- Schedules computation to maximize GPU utilization
14.2 TorchDynamo — Bytecode Interception
TorchDynamo installs a Python frame evaluation callback via CPython's _PyEval_EvalFrameDefault override API (PEP 558). This is a CPython hook that lets C code intercept bytecode execution.
When Python is about to execute a decorated function:
Normal Python execution:
Python → CPython eval loop → execute bytecodes
With TorchDynamo:
Python → Dynamo's callback fires
→ Dynamo reads bytecodes using dis/bytecode analysis
→ Classifies each instruction: tensor op? Python op? control flow?
→ Extracts tensor operations into an FX graph
→ For unsupported ops: breaks graph, falls back to Python
→ Compiles captured graph via AOTAutograd + Inductor
→ Installs guards (fast runtime checks)
→ On future calls: checks guards, dispatches to compiled artifact
Guards are the key innovation. When Dynamo captures a graph, it records every assumption it made:
- "x.shape[0] == 4"
- "x.dtype == torch.float32"
- "model.training == False"
These become fast Python checks on each call:
# Generated guard code (simplified):
def check_guards(x, model):
return (x.shape[0] == 4 and
x.dtype == torch.float32 and
not model.training)
If any guard fails, Dynamo recompiles for the new configuration. The compiled artifacts are cached by guard configuration, so "x.shape[0] == 4 and float32" and "x.shape[0] == 8 and float32" are two separate compiled graphs, both cached.
14.3 AOTAutograd — Capturing the Backward Graph
By default, PyTorch builds the backward graph lazily during the forward pass. For compilation, we need the backward graph ahead of time so it can be optimized along with the forward.
AOTAutograd (Ahead-Of-Time Autograd) traces both forward and backward simultaneously:
- Runs the forward with FakeTensors (no data, just shapes/dtypes)
- Runs autograd on the traced forward to symbolically derive the backward
- Produces a joint FX graph containing both forward and backward as one graph
- Applies
functionalization— converts all in-place ops and aliasing ops into pure functional equivalents (required for correct compilation)
The joint graph is then split into a forward graph (returned to the caller) and a backward graph (stored for when .backward() is called).
14.4 TorchInductor — Code Generation
Inductor takes the FX graph from AOTAutograd and generates code:
For NVIDIA GPUs: generates Triton code. Triton is a Python-like DSL for writing GPU kernels. It handles thread indexing, memory coalescing, and warp management automatically — you write scalar code; Triton vectorizes it.
# What Inductor might generate for relu + add:
@triton.jit
def relu_add_kernel(x_ptr, y_ptr, out_ptr, n: tl.constexpr):
pid = tl.program_id(0)
block = tl.arange(0, n)
x = tl.load(x_ptr + pid * n + block)
y = tl.load(y_ptr + pid * n + block)
out = tl.maximum(x, 0) + y # fused relu + add in one pass
tl.store(out_ptr + pid * n + block, out)
Fusion: instead of launching two kernels (one for relu, one for add), Inductor fuses them into one. The intermediate result (after relu) stays in GPU registers — no round-trip to global memory.
For CPUs: generates C++ with OpenMP parallelism and AVX vectorization hints.
14.5 The Meta Device — Shape Analysis Without Data
Before generating any kernel, Inductor must know the output shape of every operation. It uses the Meta device for this:
A Meta tensor has shape, dtype, device = "meta", but no storage — no memory allocated, no bytes of data. Operations on Meta tensors execute "Meta kernels" that compute output shapes without performing any arithmetic.
x = torch.empty(4, 128, device='meta') # 0 bytes allocated
y = torch.nn.Linear(128, 64)(x) # Meta kernel: (4,128) @ (128,64) → (4,64)
print(y.shape) # torch.Size([4, 64])
print(y.numel()) # 256 — but no data exists
Every custom operator MUST register a Meta kernel. Without one, torch.compile cannot propagate shapes and will raise:
NotImplementedError: Could not run 'myops::my_op' with Meta key.
The Meta kernel need only return at::empty_like(input) for element-wise ops, or compute the correct output shape for reshape/reduction ops.
Chapter 15: GPU Architecture — From Silicon to CUDA Kernels
15.1 Why GPUs Exist: The Throughput vs Latency Tradeoff
A modern CPU (e.g., Intel Xeon with 32 cores) is optimized for latency — executing a single complex instruction stream as fast as possible. It has:
- Large caches (L1: 32KB, L2: 256KB, L3: 16MB per core)
- Branch prediction hardware (avoids stalls from if/else)
- Out-of-order execution (reorders instructions for efficiency)
- Speculative execution (pre-executes likely branches)
This complexity lets a CPU execute a 10,000-instruction sequential program in microseconds. But it scales poorly to parallel workloads.
A GPU (e.g., NVIDIA A100) is optimized for throughput — executing thousands of identical operations simultaneously. It has:
- 6912 CUDA cores (simple integer ALUs)
- 432 Tensor Cores (4×4 matrix multiply units)
- Minimal caching (L1: 192KB/SM, L2: 40MB shared)
- No branch prediction, no out-of-order execution per core
- Massive memory bandwidth: 2 TB/s (vs CPU's ~100 GB/s)
The tradeoff: a GPU cannot run a sequential 10,000-instruction program faster than a CPU. But running the same operation on 1 million data points is 100× faster on GPU because all 1 million operations run simultaneously.
15.2 The SM — Streaming Multiprocessor
The A100 has 108 Streaming Multiprocessors (SMs). Each SM is an independent processing unit containing:
One SM:
├── 4 Warp Schedulers (each schedules 32-thread warps)
├── 64 INT32 units (integer arithmetic)
├── 64 FP32 units (floating-point arithmetic)
├── 32 FP64 units (double precision)
├── 4 Tensor Cores (4×4 matrix multiply in one cycle)
├── 192 KB L1 cache / shared memory (configurable split)
└── Register file: 256 KB (65,536 × 32-bit registers)
An SM can hold up to 2048 threads in flight simultaneously ("resident threads"). It switches between warps each clock cycle — when one warp stalls waiting for memory, the SM immediately executes another warp. This latency hiding is how GPUs tolerate their slower memory system.
15.3 The CUDA Execution Model: SIMT
CUDA uses a programming model called SIMT (Single Instruction, Multiple Threads). You write code for one thread; the hardware runs that same code on thousands of threads simultaneously.
Thread: The smallest unit. Runs one instance of your kernel.
Warp: 32 threads that execute the SAME instruction simultaneously.
This is the fundamental dispatch unit of the GPU.
Block: 1–1024 threads grouped together. Threads in a block:
- Run on the same SM
- Share the SM's shared memory
- Can synchronize with __syncthreads()
Grid: Many blocks. Blocks run independently across all SMs.
When you launch a kernel:
my_kernel<<<grid_dim, block_dim, shared_mem, stream>>>(args...);
grid_dim × block_dim total threads are created. The GPU scheduler distributes blocks across SMs. Within each SM, threads are grouped into warps of 32.
15.4 Warp Execution — Why 32 Threads Must Stay Synchronized
A warp's 32 threads share one program counter. At each clock cycle, the warp executes one instruction, and all 32 threads execute it in parallel (on different data).
Warp divergence: if threads in a warp take different branches:
if (thread_id % 2 == 0) {
// Even threads do this
x = x * 2.0f;
} else {
// Odd threads do this
x = x + 1.0f;
}
The GPU CANNOT execute both branches simultaneously. It:
- Executes
x = x * 2.0fwith even threads active, odd threads masked off - Executes
x = x + 1.0fwith odd threads active, even threads masked off
Result: 2× longer execution. Each half of the warp idles while the other executes. Minimizing warp divergence is a primary GPU optimization concern.
15.5 The Memory Hierarchy — The Critical Performance Factor
| Memory | Location | Size (A100) | Latency | Bandwidth |
|---|---|---|---|---|
| Registers | Per-thread (on SM) | 256 KB/SM | 0 cycles | ~20 TB/s |
| Shared Memory | Per-block (on SM) | 48-192 KB/SM | ~5 cycles | ~12 TB/s |
| L1/Texture Cache | Per-SM | 192 KB/SM | ~20 cycles | ~8 TB/s |
| L2 Cache | GPU-wide | 40 MB | ~200 cycles | ~2 TB/s |
| HBM2e (Global Mem) | Off-chip DRAM | 80 GB | ~800 cycles | 2 TB/s |
The golden rule: every read from global memory costs ~800 cycles. If your kernel reads each piece of data once and computes many operations on it, global memory cost is amortized. If your kernel reads data once, computes one operation, and writes back — you're memory-bound: the bottleneck is memory, not compute.
FlashAttention's breakthrough was recognizing that the standard attention algorithm is memory-bound (reads Q, K, V matrices multiple times) and restructuring the computation to keep everything in L1/shared memory using tiling.
15.6 Memory Coalescing — Why Access Pattern Matters
When 32 threads in a warp each read one float from global memory, the GPU issues a memory transaction. If the 32 addresses are contiguous (consecutive elements of an array), the transaction fetches one 128-byte cache line — one transaction total.
If the 32 addresses are strided (e.g., every other element) or random (scattered), the GPU must issue multiple transactions — 2× or 32× slower respectively.
Coalesced access (good):
Thread 0: address 0×4 = float at byte 0
Thread 1: address 1×4 = float at byte 4
Thread 2: address 2×4 = float at byte 8
...
Thread 31: address 31×4 = float at byte 124
→ One 128-byte transaction covers all 32 reads.
Strided access (bad):
Thread 0: address 0×8 = float at byte 0
Thread 1: address 1×8 = float at byte 8
...every other float, stride=2
→ Two transactions, 50% bandwidth wasted
This is why matrix transposition before matmul is sometimes applied — to ensure the memory access pattern in the CUDA kernel is coalesced.
15.7 Warp Shuffle Instructions — Register-Level Communication
Normally, threads share data via shared memory (write → barrier → read, ~5 cycles). Warp shuffle instructions (__shfl_*) let threads exchange register values directly within a warp without touching memory — ~1 cycle.
Available shuffles:
__shfl_sync(mask, val, src_lane) // all threads get val from lane src_lane
__shfl_up_sync(mask, val, delta) // get val from lane (current - delta)
__shfl_down_sync(mask, val, delta) // get val from lane (current + delta)
__shfl_xor_sync(mask, val, laneMask) // get val from lane (current XOR laneMask)
mask = 0xffffffff means all 32 lanes participate.
The butterfly reduction using __shfl_xor_sync:
Goal: find the sum of values across all 32 threads.
Round 1 (laneMask=16):
Lane 0 gets lane 16's value. Lane 0 now holds sum of elements 0 and 16.
Lane 1 gets lane 17's value. Lane 1 now holds sum of elements 1 and 17.
... (simultaneously for all 32 pairs)
Round 2 (laneMask=8):
Lane 0 gets lane 8's value. Lane 0 now holds sum of elements 0, 8, 16, 24.
...
Round 3 (laneMask=4): Each thread holds sum of 8 elements.
Round 4 (laneMask=2): Each thread holds sum of 16 elements.
Round 5 (laneMask=1): Each thread holds sum of all 32 elements.
5 rounds = log₂(32) = 5 communication steps. Every thread simultaneously holds the global sum. No shared memory, no barriers.
15.8 The warp_softmax Kernel Step by Step
Lab 02's kernel computes softmax over a 2D tensor [rows, vocab_size] where each warp handles one row:
Tensor: [batch × seq_len, vocab_size] = [rows, V]
Lane k handles elements: k, k+32, k+64, ..., k+32*(V/32-1)
Each lane processes V/32 elements (1000 for vocab=32000)
Step 1 — Find row max (prevents exp() overflow):
Each lane: local_max = max(input[k], input[k+32], ..., input[k+32*(V/32-1)])
Butterfly reduction: all lanes now hold row_max
Step 2 — Compute exp(x - row_max) and accumulate sum:
Each lane: for i in range(k, V, 32):
val = exp(input[i] - row_max)
output[i] = val
local_sum += val
Butterfly reduction: all lanes now hold row_sum
Step 3 — Normalize:
Each lane: inv_sum = 1.0 / row_sum (multiply cheaper than divide)
for i in range(k, V, 32): output[i] *= inv_sum
Three passes over the data. All intermediate values (row_max, partial sums) live in registers — no shared memory needed. This is why the kernel is fast: minimal memory traffic, maximum parallelism.
Chapter 16: Custom C++ Operators — TORCH_LIBRARY
16.1 The Two-Level Registration System
PyTorch separates what an operator is from how it's implemented on each device. This separation is the dispatcher's core design.
Level 1: Schema registration — defines the operator's name and type signature, visible to all backends:
TORCH_LIBRARY(myops, m) {
// Schema language:
// "namespace::name(arg_type arg_name, ...) -> return_type"
// Arg types: Tensor, Tensor?, Tensor[], int, int[], float, bool,
// ScalarType, Device, str, Scalar
m.def("warp_softmax(Tensor self, int dim) -> Tensor");
}
This creates torch.ops.myops.warp_softmax in Python. The operator exists in the registry. Calling it without an implementation registered for the current dispatch key will error.
Level 2: Implementation registration — binds implementations to specific dispatch keys:
// CPU implementation
TORCH_LIBRARY_IMPL(myops, CPU, m) {
m.impl("warp_softmax", &warp_softmax_cpu); // function pointer
}
// CUDA implementation
TORCH_LIBRARY_IMPL(myops, CUDA, m) {
m.impl("warp_softmax", &warp_softmax_cuda);
}
// Meta implementation — REQUIRED for torch.compile
TORCH_LIBRARY_IMPL(myops, Meta, m) {
m.impl("warp_softmax", &warp_softmax_meta); // returns empty_like(input)
}
16.2 How Registration Actually Works at Runtime
TORCH_LIBRARY and TORCH_LIBRARY_IMPL expand to static initializers — C++ code that runs when the .so file is loaded. They call into c10::Dispatcher::singleton() to register the operator:
// What TORCH_LIBRARY expands to (simplified):
static auto registration = c10::RegisterOperators()
.op("myops::warp_softmax(Tensor self, int dim) -> Tensor",
c10::DispatchKey::CPU, &warp_softmax_cpu)
.op("myops::warp_softmax(Tensor self, int dim) -> Tensor",
c10::DispatchKey::CUDA, &warp_softmax_cuda);
When your .so is loaded via torch.ops.load_library("warp_softmax_ext.so"), these static initializers run, and the operator is available immediately.
16.3 The Two Autograd Paths
Path A — Composite (automatic differentiation):
If your forward kernel is built from differentiable ATen ops (like the CPU kernel in Lab 02 that uses .amax(), .exp(), .sum(), /), PyTorch differentiates through them automatically. The Autograd key uses CompositeImplicitAutograd — it traces through your implementation to find the gradient.
Path B — Explicit autograd (manual backward):
If your CUDA kernel is not built from differentiable ATen ops, you must register an explicit backward:
torch::autograd::Function<SoftmaxBackward>::apply = [](
const at::Tensor& grad_output,
const at::Tensor& output) -> at::Tensor {
// Softmax backward: grad_input = output * (grad - (grad * output).sum(-1, true))
auto dot = (grad_output * output).sum(-1, true);
return output * (grad_output - dot);
};
TORCH_LIBRARY_IMPL(myops, Autograd, m) {
m.impl("warp_softmax", torch::autograd::autogradNotImplementedFallback());
// OR: m.impl("warp_softmax", &warp_softmax_autograd);
}
16.4 OpCheck — The Complete Validation Suite
torch.library.opcheck tests your operator against all dispatch keys automatically:
import torch
torch.ops.load_library("warp_softmax_ext.so")
# Test with a simple input
x = torch.randn(4, 128, dtype=torch.float32, requires_grad=True)
if torch.cuda.is_available():
x = x.cuda()
results = torch.library.opcheck(
torch.ops.myops.warp_softmax,
args=(x, -1),
kwargs={},
)
# Raises if any dispatch key implementation is wrong
What OpCheck verifies:
- CPU/CUDA correctness: output matches reference (
F.softmax) within 1e-5 - Autograd correctness:
torch.autograd.gradcheckon differentiable inputs - Meta kernel: shape propagation with
device='meta'tensors - FakeTensor mode: compatibility with
torch.compile's shape tracking - Out-of-place invariant: operator does not modify its inputs in-place unexpectedly
If OpCheck passes, your operator is safe in all contexts: eager mode, training, inference, torch.compile, quantization pipelines.
Part V — TensorFlow
Chapter 17: TF1 to TF2 — Two Execution Philosophies
17.1 The TF1 Philosophy: Define-Then-Run
TensorFlow 1.x was designed by Jeff Dean and the Google Brain team in 2015, influenced by Theano and the needs of large-scale production ML.
The core insight: if you separate graph definition from graph execution, you can:
- Ship the graph (a serialized Protocol Buffer file) to mobile devices, servers, or TPUs
- Optimize the graph globally before running it (constant folding, CSE, operation fusion)
- Distribute it across machines without re-serializing Python code
- Cache the compiled form and reuse it for millions of inference requests
The penalty: you cannot inspect intermediate values during execution. The graph runs entirely in C++ without Python touching it.
# TF1 workflow
import tensorflow as tf
# PHASE 1: Build the graph (nothing executes)
x = tf.placeholder(tf.float32, shape=[None, 784], name='input')
W = tf.Variable(tf.random.normal([784, 256]), name='weights')
b = tf.Variable(tf.zeros([256]), name='bias')
h = tf.nn.relu(tf.matmul(x, W) + b, name='hidden')
loss = tf.reduce_mean(tf.square(h - target), name='loss')
optimizer = tf.train.AdamOptimizer(lr=0.001)
train_op = optimizer.minimize(loss)
# PHASE 2: Execute (everything runs in C++)
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
for step in range(1000):
batch_x, batch_y = get_batch()
loss_val, _ = sess.run([loss, train_op], {x: batch_x, target: batch_y})
The graph is a Protocol Buffer — Google's binary serialization format. The graph can be saved with tf.saved_model.save() and loaded on any device with the TF runtime, including mobile phones (TFLite) and Google TPUs.
17.2 TF2's Eager Execution: Define-By-Run
Python programmers found TF1's two-phase model confusing and hard to debug. TF2 (released 2019) switched to eager execution by default:
# TF2 eager — operations execute immediately
import tensorflow as tf
W = tf.Variable(tf.random.normal([784, 256]))
b = tf.Variable(tf.zeros([256]))
x = tf.constant(batch_x) # a real tensor, not a placeholder
h = tf.nn.relu(x @ W + b) # runs NOW, returns a real tensor
print(h.shape) # (batch_size, 256) — immediate
print(h.numpy()[:2]) # can inspect values directly
Like PyTorch, operations execute immediately and return concrete tensors. Debugging is trivial — add print() statements anywhere.
The cost: no static optimization, no graph export. Each Python call has interpreter overhead.
The bridge: @tf.function re-introduces graph compilation as an opt-in feature, giving you the best of both worlds.
Chapter 18: tf.function — AutoGraph, Tracing, and ConcreteFunctions
18.1 What Happens When Python Hits @tf.function
The first time you call a @tf.function-decorated function, TF runs a multi-stage pipeline:
Stage 1 — AutoGraph transformation: TF rewrites your Python source code. Python control flow structures are converted to TF graph ops:
| Python | AutoGraph rewrites to |
|---|---|
if condition: | tf.cond(condition, true_fn, false_fn) |
for i in range(n): | tf.while_loop(...) with loop body as a function |
while condition: | tf.while_loop(...) |
x = x + 1 (inside loop) | x = tf.add(x, 1) with proper loop variable handling |
This transformation happens at the Python source level — TF uses Python's inspect module to get the source code, then rewrites it using the gast library (Generic AST).
Stage 2 — Tracing: The AutoGraph-transformed function runs with SymbolicTensor objects (analogous to PyTorch's Proxy objects). Every TF op call records a node in a FuncGraph — TF's internal graph representation.
Stage 3 — Shape inference: TF propagates shapes through all nodes in the FuncGraph. Operations that don't know their output shape at trace time are marked with unknown dimensions.
Stage 4 — Compilation to ConcreteFunction: The FuncGraph is compiled into a ConcreteFunction — a C++ execution artifact bound to a specific input signature. XLA may further compile this to hardware-specific machine code.
Stage 5 — Caching: The ConcreteFunction is cached. Subsequent calls with matching signatures (same shapes and dtypes) route to the cached artifact — no Python execution, no tracing overhead.
18.2 The Complete List of Retrace Triggers
A retrace is expensive (re-runs AutoGraph + tracing + compilation). You must understand every trigger:
Trigger 1: New input shape (without input_signature)
@tf.function
def f(x): return x * 2
f(tf.ones([3])) # trace 1: shape (3,)
f(tf.ones([4])) # RETRACE: shape (4,) is new
Prevention: @tf.function(input_signature=[tf.TensorSpec(shape=[None], dtype=tf.float32)])
Trigger 2: New input dtype
f(tf.ones([3], dtype=tf.float32)) # trace 1
f(tf.ones([3], dtype=tf.float64)) # RETRACE: different dtype
Trigger 3: Change in Python non-tensor arguments
@tf.function
def f(x, n_heads): # n_heads is Python int, not tf.Tensor
return tf.split(x, n_heads, axis=-1)
f(x, n_heads=4) # trace 1: n_heads=4 baked in as compile-time constant
f(x, n_heads=8) # RETRACE: n_heads=8 requires new graph structure
AutoGraph needs Python values at trace time to build the graph (how many splits to create). Changing a Python value creates a different graph.
Trigger 4: Changed Python closure
threshold = 0.5
@tf.function
def f(x): return x > threshold # captures Python variable 'threshold'
threshold = 0.9
f(x) # RETRACE: closure value changed
Trigger 5: tf.Variable created inside the function (error, not retrace)
@tf.function
def bad():
v = tf.Variable(0.0) # FORBIDDEN after first trace
return v
# Second call raises: ValueError: tf.function only supports tf.Variables
# created on the first call.
Prevention pattern: always create Variables outside @tf.function:
v = tf.Variable(0.0) # created once, outside
@tf.function
def good(x):
v.assign_add(x) # use the existing Variable
return v
18.3 AutoGraph's Transformation in Detail
Let's trace exactly what AutoGraph does to a loop:
# Original Python:
@tf.function
def sum_to_n(n):
total = tf.constant(0)
for i in tf.range(n): # tf.range, not Python range!
total = total + i
return total
AutoGraph rewrites this to (approximately):
def sum_to_n(n):
total = tf.constant(0)
# Loop body as a separate function
def loop_body(i, total):
total = total + i
return total
# TF while_loop handles the actual iteration in C++
total = tf.while_loop(
cond=lambda i, _: i < n,
body=loop_body,
loop_vars=[tf.constant(0), total]
)[1]
return total
tf.while_loop is a TF graph op that encodes the loop entirely within the graph. The loop runs in C++ without returning to Python. This is how TF achieves Python-free execution of loops.
Note: if you use Python range(n) instead of tf.range(n), AutoGraph unrolls the loop at trace time — creating n nodes in the graph. For variable n, this fails. Always use tf.range for dynamic loops inside @tf.function.
Chapter 19: GradientTape, XLA, and MLIR
19.1 GradientTape — TF's Explicit Backward Recording
PyTorch records gradients implicitly (every operation on a requires_grad tensor is automatically recorded). TF2 uses explicit recording via tf.GradientTape:
x = tf.Variable([1.0, 2.0, 3.0]) # Variables are automatically watched
with tf.GradientTape() as tape:
# Everything inside this block is recorded
y = x ** 2 # element-wise square
loss = tf.reduce_sum(y) # sum: loss = x[0]² + x[1]² + x[2]²
# Compute gradients AFTER the tape context closes
grads = tape.gradient(loss, x)
print(grads) # [2.0, 4.0, 6.0] — ∂loss/∂xᵢ = 2xᵢ
tf.Variable objects are automatically watched. For tf.Tensor constants, you must explicitly watch:
with tf.GradientTape() as tape:
tape.watch(constant_tensor)
loss = f(constant_tensor)
grad = tape.gradient(loss, constant_tensor)
By default, a tape can only be used once — the recorded operations are freed after gradient() is called. Use tape = tf.GradientTape(persistent=True) to call gradient() multiple times (like PyTorch's retain_graph=True).
19.2 XLA — Accelerated Linear Algebra
XLA is Google's compiler for linear algebra operations. When you annotate with @tf.function(jit_compile=True) or use tf.device('/GPU:0') with XLA enabled, the FuncGraph is lowered to HLO (High-Level Operations) and compiled by XLA.
HLO is XLA's IR — a set of ~100 primitive operations (dot product, element-wise, reduce, reshape, select, etc.). Every TF op eventually lowers to one or more HLO ops.
XLA performs aggressive optimizations on HLO:
- Operation fusion: merges adjacent element-wise ops into one kernel
- Buffer allocation: assigns memory to each HLO op's output, reusing buffers when possible
- Loop tiling: tiles computations to fit in cache, improving memory locality
- Layout optimization: chooses the best tensor layout (row-major vs column-major) for each op to minimize memory transpose costs
XLA then lowers HLO to machine code via LLVM for CPU or NVPTX for NVIDIA GPUs (or the TPU backend for Google's hardware). The generated code is highly optimized, often outperforming manually written CUDA kernels.
19.3 MLIR — Multi-Level Intermediate Representation
MLIR (Multi-Level Intermediate Representation) is Google's framework for building compilers, introduced in 2020. TF now uses MLIR as a bridge between TF's high-level graph and XLA's HLO.
Why multiple levels? Different optimization passes work at different abstraction levels:
- At the TF graph level: common subexpression elimination, shape inference, dead code elimination
- At the XLA HLO level: fusion, buffer assignment, layout optimization
- At the machine code level: register allocation, instruction scheduling
MLIR allows each optimization to operate at the right abstraction level using dialects — pluggable extensions that define new operations and types. TF defines:
tfdialect: TF-level operationsmhlodialect: the HLO equivalent in MLIRlinalgdialect: linear algebra operations with explicit loop structurememrefdialect: explicit memory operations
The compilation pipeline is a sequence of lowering passes:
tf dialect → mhlo dialect → linalg dialect → affine dialect → LLVM IR → machine code
Each lowering step progressively descends toward machine code while applying level-appropriate optimizations. This multi-level approach is why TF on TPUs and GPUs often outperforms manually tuned libraries — the compiler has complete knowledge of the computation at every level.
Part VI — Numerical Precision
Chapter 20: IEEE 754 — How Computers Store Numbers
20.1 Why Computers Can't Represent All Real Numbers
Computers store everything in binary (base 2). A 32-bit integer can represent exactly 2³² values. But real numbers are infinite — there are infinitely many values between 0 and 1 alone.
Floating-point is the compromise: represent a wide range of values approximately, with the precision concentrated near 1.0 and thinning out at extreme values (very large or very small).
20.2 The IEEE 754 Standard — Bit by Bit
IEEE 754 defines how floating-point numbers are stored. Every float is represented as:
\[ \text{value} = (-1)^{\text{sign}} \times 1.\text{mantissa}_2 \times 2^{\text{exponent} - \text{bias}} \]
For float32 (32 bits total):
Bit 31 | Bits 30-23 | Bits 22-0
sign (1 bit)| exponent (8) | mantissa (23)
- Sign: 0 = positive, 1 = negative
- Exponent: stored with a bias of 127. Actual exponent = stored_value − 127. Range: 2^(0-127) = 2^-127 to 2^(255-127) = 2^128
- Mantissa: the fractional part after the leading 1 (which is implicit — not stored). 23 bits gives about 7 decimal digits of precision.
Example: how is −6.5 stored?
- Sign: negative → bit 31 = 1
6.5in binary:110.1=1.101 × 2^2- Exponent: 2 + 127 = 129 =
10000001in binary - Mantissa:
101followed by 20 zeros:10100000000000000000000
Full bit pattern: 1 10000001 10100000000000000000000
20.3 Special Values — NaN, Infinity, Denormals
IEEE 754 reserves special exponent values for non-numeric cases:
| Exponent bits | Mantissa bits | Meaning |
|---|---|---|
| 00000000 | 00000...000 | ±0 |
| 00000000 | nonzero | Denormal number (very small, near 0) |
| 11111111 | 00000...000 | ±Infinity |
| 11111111 | nonzero | NaN (Not a Number) |
Infinity: 1.0 / 0.0 = +Inf. Operations on Infinity propagate: Inf + 1 = Inf, Inf - Inf = NaN.
NaN: result of undefined operations: 0.0 / 0.0, sqrt(-1), Inf - Inf. NaN is "poisonous" — any arithmetic involving NaN produces NaN. This is why NaN in one layer propagates through the entire network.
Denormals (subnormal numbers): when the exponent is all zeros, the leading 1 is absent, allowing extremely small values near zero. Operations on denormals are 10-100× slower on some hardware because the FPU falls back to software emulation.
20.4 Precision Limits — When 1 + ε = 1
float32 has 23 mantissa bits. The smallest value that, when added to 1.0, gives a result different from 1.0 is called machine epsilon:
\[ \varepsilon_{\text{fp32}} = 2^{-23} \approx 1.19 \times 10^{-7} \]
This means: 1.0 + 1e-8 == 1.0 in float32. The 1e-8 is below the precision threshold and is simply lost.
Catastrophic cancellation: subtracting two nearly-equal large numbers loses significant digits:
x = 1.000001 (float32 representation: 1.0 + 1e-6)
y = 1.000000 (float32 representation: 1.0)
x - y = 1e-6 (correct)
But if x = 1000000.001 and y = 1000000.000:
float32 precision at scale 1e6 is ε × 1e6 = 1.19e-7 × 1e6 = 0.119
The difference 0.001 is below the representable precision!
x - y = 0.0 in float32 (wrong — should be 0.001)
This happens in:
- Softmax with large logits:
sum(exp(large_values))is huge; individualexp(x_i)may be small relative to the sum - Layer normalization: subtracting the mean from large-valued activations
- Log probabilities:
log(p)wherepis very close to 0 or 1
20.5 The ULP — Measuring Floating-Point Error
"Units in the Last Place" (ULP) is the standard measure of floating-point error. 1 ULP is the distance between a float and the next representable float.
Near 1.0: 1 ULP ≈ 1.19e-7 (float32 epsilon) Near 1e8: 1 ULP ≈ 8.0 (much larger!)
When your gradient check requires tolerance 1e-5, you're saying "allow up to about 100 ULP error at values near 1.0". For values near 100, the absolute tolerance should be 100 × 1.19e-7 × 100 = 1.19e-3. This is why gradient checks use relative tolerance, not absolute.
Chapter 21: FP16, BF16, and Mixed Precision Training
21.1 Why Not Always Use float32?
float32 has excellent precision and range. But it uses 4 bytes per value. For a 7B parameter model:
- float32: 7e9 × 4 bytes = 28 GB just for weights
- float16: 7e9 × 2 bytes = 14 GB
- int8: 7e9 × 1 byte = 7 GB
Beyond storage: float32 arithmetic is 2× slower than float16 on modern GPUs with Tensor Cores. The A100's Tensor Core throughput is 312 TFLOPs (float16) vs 77 TFLOPs (float32) — 4× faster for matrix multiplications.
21.2 float16 — High Performance, Dangerous Range
float16 (half precision):
Bit 15 | Bits 14-10 | Bits 9-0
sign (1) | exponent(5)| mantissa(10)
- Exponent: 5 bits, bias=15. Actual range: 2^-14 to 2^15 = 0.00006 to 65504
- Mantissa: 10 bits ≈ 3.3 decimal digits of precision
- Maximum value: 65504
The danger: during training, gradient norms, loss values, and large weight matrices can easily exceed 65504 → overflow → Infinity → NaN → corrupted training.
Additionally, very small gradients (common in early training or deep networks) can underflow to 0 → parameters stop updating → training stalls.
21.3 Loss Scaling — The Solution to FP16 Overflow and Underflow
The trick: multiply the loss by a large scalar (e.g., 2^15 = 32768) before backpropagation. This shifts all gradients upward by the same factor, preventing underflow. Before applying gradients to weights, divide by the same scalar.
scaler = torch.cuda.amp.GradScaler()
# Training loop
optimizer.zero_grad()
with torch.autocast(device_type='cuda', dtype=torch.float16):
output = model(input)
loss = criterion(output, target)
scaler.scale(loss).backward() # multiplies loss by scaler factor (e.g., 32768)
scaler.step(optimizer) # unscales gradients, applies them
scaler.update() # adjusts scale factor based on inf/nan detection
The GradScaler dynamically adjusts the scale factor: if gradients overflow (contain Inf/NaN), it halves the scale and skips this optimizer step; if gradients are clean for a while, it doubles the scale to catch any underflowing gradients.
21.4 BF16 — The Training-Safe Compromise
BF16 (Brain Float 16, developed by Google for TPUs):
Bit 15 | Bits 14-7 | Bits 6-0
sign (1) | exponent(8)| mantissa(7)
- Exponent: 8 bits — same as float32! Range: 2^-126 to 2^127 ≈ 10^-38 to 10^38
- Mantissa: 7 bits ≈ 2.3 decimal digits of precision
- No overflow or underflow risk during training (same range as float32)
- No loss scaling needed
BF16 vs FP16 for training:
- BF16: safe range, lower precision. Training is stable.
- FP16: dangerous range, higher precision. Needs loss scaling.
Modern NVIDIA hardware (Ampere A100, Hopper H100) supports BF16 natively with the same Tensor Core throughput as FP16. BF16 is now the default training format for most LLM training runs.
21.5 AMP — Automatic Mixed Precision
torch.autocast handles the dtype selection automatically:
with torch.autocast(device_type='cuda', dtype=torch.float16):
# These ops run in float16 (faster, sufficient precision):
output = model.layers(input) # matmul, conv2d
# These ops are forced to float32 (numerically sensitive):
loss = F.cross_entropy(logits, targets) # softmax + log inside
# layer norm, softmax, batch norm also run in float32
The autocast context uses a table of operations and their recommended dtypes. Matmul and conv can lose precision without impacting final model quality. Softmax, layer norm, and loss functions are sensitive to precision — they stay in float32.
Part VII — Synthesis and Practice
Chapter 22: How Everything Connects — The Principal Engineer's Map
MATH LAYER
Chain Rule → Jacobians → VJPs → Reverse Mode AD
↓
PYTHON LAYER
CPython bytecode → GIL → pybind11 → C++
↓
PYTORCH C++ LAYER
c10 types → ATen ops → Dispatcher → DispatchKeySet
↓ ↓
Autograd Key CUDA/CPU Keys
(records DAG) (compute kernels)
↓
BACKWARD DAG
Node→Edge→AccumulateGrad → leaf.grad
↓
GRAPH CAPTURE
FX IR (symbolic trace) → torch.compile (Dynamo bytecodes + guards)
↓
COMPILATION
AOTAutograd → joint forward+backward graph
TorchInductor → Triton (GPU) / C++ (CPU)
↓
CUSTOM EXTENSIONS
TORCH_LIBRARY schema → TORCH_LIBRARY_IMPL per key
(CPU, CUDA, Meta, PrivateUse1/NPU)
How each lab maps to production work:
Lab 01 (minigrad) → Production skill: Debugging gradient issues
- When a gradient is wrong: you know to check each
Function.backwardfor the VJP formula - When a gradient is None: you know to check
requires_grad,no_grad()scope, ordetach() - When a model doesn't converge: you know to run
gradcheckto find the broken backward - When writing a custom backward for a fused attention kernel: you apply Lab 01 patterns directly
Lab 02 (warp_softmax) → Production skill: Writing NPU kernels
- The
TORCH_LIBRARYregistration pattern is identical for CPU, CUDA, and NPU backends - The
Metakernel requirement is universal — every operator in the ecosystem needs it - The warp reduction technique applies to any per-row reduction: max-pooling, layer norm, etc.
opcheckis used to validate every operator in PyTorch's own test suite
Chapter 23: Lab Guidance — Building minigrad and warp_softmax
23.1 Lab 01: minigrad Implementation Order
Start from the simplest operations and build up. Each step gives you a working test before the next.
Step 1: Implement _build_topo on Tensor.
This is the topological sort. Without it, backward() cannot run. Start here:
def _build_topo(self):
topo, visited = [], set()
def dfs(t):
if id(t) in visited: return
visited.add(id(t))
if t.grad_fn:
for inp in t.grad_fn._input_tensors:
if isinstance(inp, Tensor): dfs(inp)
topo.append(t)
dfs(self)
return topo
Step 2: Implement backward() on Tensor.
Initialize gradient, call _build_topo, traverse in reverse, call each grad_fn's backward, accumulate into leaves. Verify with the smoke test at bottom of lab.py.
Step 3: Implement Add (forward + backward).
Backward must handle broadcasting via _unbroadcast. Test: a + b where a.shape=(3,), b.shape=(4,3).
Step 4: Implement Mul, MatMul, Neg, Div, Pow.
These teach: saving inputs for backward, the VJP formulas for each op.
Step 5: Implement ReLU, Sigmoid, Tanh, Exp, Log.
Key insight: Sigmoid.forward should save the output (not input) because d(sigmoid)/dx = sigmoid(x)(1 - sigmoid(x)) is expressed in terms of output.
Step 6: Implement softmax using Max, Exp, Sum, Div.
Apply the max-subtraction trick for numerical stability. All ops you implement in steps 3-5 compose naturally.
Step 7: Implement cross_entropy_loss using log_softmax.
The full MLP training loop should then converge.
Numerical gradient check — what it is: approximate the derivative numerically using finite differences, compare with your analytic gradient:
numerical_grad[i] ≈ (f(x + ε·eᵢ) - f(x - ε·eᵢ)) / 2ε
If abs(analytic - numerical) / max(abs(analytic), abs(numerical)) < 1e-5, your backward is correct.
23.2 Lab 02: warp_softmax — Reading Before Building
Read the source files in this order:
-
csrc/warp_softmax.cpp: understandTORCH_LIBRARY(schema),TORCH_LIBRARY_IMPL(CPU + Meta),warp_softmax_cpu(ATen ops for CPU),warp_softmax_meta(returnsempty_like). -
csrc/warp_softmax.cu: readwarp_reduce_maxandwarp_reduce_sumfirst — these are the butterfly reduction utilities. Then readwarp_softmax_kernel— trace through the three steps (find max, compute exp+sum, normalize). Map each line to the hardware concepts from Chapter 15. -
setup.py: understand howtorch.utils.cpp_extension.CUDAExtensioncompiles your C++/CUDA and links against libtorch. -
solution.py: understand howopcheckis called and what it verifies.
23.3 Lab 03: tf.function Tracing — Implementation Order
Chapter 18 is the theory; the lab makes it mechanical. Order matters here too:
trace_and_inspectfirst — until you can see aFuncGraph's ops, captures, and signature, the rest is blind. Key API:fn.get_concrete_function(*inputs).graph.RetraceProbe— the cleverness is that a Python counter inside the traced body is the retrace counter, because Python side effects fire only at trace time (Chapter 18's central fact, now weaponized as instrumentation).demonstrate_retracing— predict the three trace counts on paper before running; if your prediction misses, re-read §18's retracing rules.stabilize—tf.TensorSpec([None, ...])makes shape symbolic; verify one trace serves all batch sizes.autograph_diff, then the SavedModel/TFLite round-trips — SavedModel must be bit-exact (same graph, same weights); TFLite dynamic-range must be close-but-not-exact (weights were perturbed to INT8). Both assertions are in the tests; understand why each bound holds.
23.4 Success Criteria
You are ready for Phase 02 when you can answer all of these from memory:
- Trace a 5-op PyTorch expression's full backward pass with specific gradient values at each node.
- Name every dispatch key in priority order and explain what each intercepts.
- Give 3 distinct triggers for
tf.functionretracing and the prevention for each. - Explain what breaks in
torch.compileif your custom op has no Meta kernel. - Draw the butterfly reduction for
warp_reduce_maxwith 8 threads (3 rounds). - Explain why BF16 doesn't need loss scaling but FP16 does.
- Write the VJP (backward formula) for matmul from the Jacobian definition.
If any answer is fuzzy, return to that chapter and re-read the internal mechanism — not the API, the mechanism.
Chapter 24: The Interview Gauntlet — Complete Answers
These are actual questions asked at senior/staff ML framework engineering interviews. The expected answer length and depth is shown.
Q: Walk me through what happens, step by step, when I call loss.backward().
A complete answer (2-3 minutes verbal):
loss.backward() calls into torch::autograd::Engine::execute(). The engine starts from loss.grad_fn with an initial gradient of torch.ones_like(loss). It builds a ready-count for each Function node — how many downstream nodes still need to send gradients to it. Processing order is determined by decrementing these counts: a node runs when its count reaches zero (all downstream contributions received).
For each node, the engine calls node.apply(input_gradients) — the virtual backward function. For built-in ops this is a generated class like MmBackward0; for custom ops it's the static backward method you defined. The function returns a tuple of gradients — one per input. Each gradient is sent to the corresponding upstream node (via next_edges_). Leaf nodes have AccumulateGrad as their node, which adds the incoming gradient to leaf.grad (or initializes it).
After backward completes, the Function nodes and their saved tensors are freed (unless retain_graph=True). This is why you can't call backward() twice on the same graph — the intermediate data is gone.
Q: What is a dispatch key and why is its priority ordering load-bearing?
The DispatchKeySet is a 64-bit bitfield attached to every tensor. Each bit position corresponds to one DispatchKey. The dispatcher takes the bitwise OR of all input tensors' key sets and calls the highest-priority key that has a registered implementation for the operation.
The priority ordering is load-bearing because the keys are semantic wrappers. Autograd sits above CUDA so that gradient recording always wraps the compute kernel — you can't record gradients without the Autograd key seeing the operation first. Functionalize sits above Autograd so that aliasing analysis (needed for torch.compile) can see the original mutation intent before autograd creates any graph nodes.
If an NPU vendor registered their key above Autograd, their device would silently bypass gradient recording. Training would appear to work (forward pass correct) but gradients would never be computed.
Q: When does tf.function retrace, and what is the performance cost?
Retrace triggers: (1) new input shapes when input_signature is not set, (2) new input dtypes, (3) change in non-tensor Python argument values (they are compile-time constants baked into the graph), (4) changed Python closures captured by the function.
The cost is: re-running AutoGraph (Python source transformation), re-tracing the function with SymbolicTensors (building a new FuncGraph), optionally re-running XLA compilation (extremely expensive — can take minutes for large models). In production, unexpected retracing is a severe latency spike. You diagnose with tf.autograph.set_verbosity(10) to print retrace events.
Prevention: always use input_signature with None for variable dimensions; convert Python-controlled iteration counts to tf.Tensor arguments; avoid Python closures that change.
Q: A model works in eager mode but gives NaN in torch.compile. How do you debug it?
Systematic approach:
Step 1: Isolate forward vs backward. Add torch.autograd.set_detect_anomaly(True) before the training loop — it adds expensive runtime checks but identifies exactly which backward operation produces NaN.
Step 2: Bisect the graph. Use torch.compile(fullgraph=False) — this allows graph breaks. Introduce a deliberate graph break at the suspected location (e.g., torch._dynamo.graph_break()) and check if NaN appears before or after the break.
Step 3: Check custom ops. If you have a custom operator, verify it has a Meta kernel and run opcheck. An incorrect Meta kernel can cause shape mismatches that produce NaN in compiled mode.
Step 4: Check numerical precision. Compiled mode may fuse operations that change floating-point evaluation order. Add torch.use_deterministic_algorithms(True) to force bit-identical behavior across modes.
Step 5: Check in-place ops. The Functionalize dispatch key converts in-place ops to functional form. If your custom backward incorrectly modifies saved tensors in-place, Functionalize may not handle it correctly, producing NaN.
Q: What is the Meta device, and why does every custom operator need a Meta kernel?
The Meta device is a special device type where tensors have shape, dtype, device=meta, but zero bytes of storage. Operations on Meta tensors execute "Meta kernels" that propagate shapes and dtypes without doing any computation.
torch.compile's TorchDynamo captures the computation as an FX graph. AOTAutograd traces through this graph using FakeTensors — which are Meta tensors augmented with symbolic size information. Every operation in the graph must have a Meta kernel so the compiler can determine output shapes without running compute kernels.
A custom operator without a Meta kernel causes torch.compile to raise NotImplementedError: Could not run 'myops::op' with the 'Meta' dispatch key. The fix is always:
TORCH_LIBRARY_IMPL(myops, Meta, m) {
m.impl("my_op", [](const at::Tensor& input, int64_t dim) {
return at::empty_like(input); // same shape, same dtype, no data
});
}
For ops that change shape (e.g., a custom reshape), the Meta kernel must compute the correct output shape from the input shape and arguments.
Q: Explain catastrophic cancellation with a concrete example, and how you'd prevent it in a softmax implementation.
Catastrophic cancellation: subtracting two nearly-equal large numbers produces a result with far fewer significant digits than either operand.
Concrete example: computing softmax([1000.0, 999.0]) in float32.
exp(1000.0) → inf (overflows float32 max ≈ 3.4e38)
exp(999.0) → inf
softmax = inf / (inf + inf) = inf / inf = NaN
Even if the values don't overflow: computing exp(100.0) / (exp(100.0) + exp(99.0)) in float16:
exp(100)≈ 2.69e43 → overflows float16 (max 65504) → Inf
Prevention — the max-subtraction trick:
softmax(x_i) = exp(x_i) / Σ exp(x_j)
= exp(x_i - M) / Σ exp(x_j - M) where M = max(x)
Subtracting M ensures all exponents are ≤ 0, so exp(x_i - M) ∈ (0, 1] — no overflow, no underflow, no cancellation. The maximum value is always exp(0) = 1.
For the normalization sum itself: compute in float32 even when the inputs are float16, to maintain precision. PyTorch's F.softmax does this automatically.
References
Foundational Papers
-
Reverse-mode AD: Baydin, A.G., Pearlmutter, B.A., Radul, A.A., Siskind, J.M. (2018). Automatic Differentiation in Machine Learning: a Survey. JMLR 18(153). — The definitive survey of automatic differentiation; explains forward/reverse modes rigorously.
-
PyTorch: Paszke, A. et al. (2019). PyTorch: An Imperative Style, High-Performance Deep Learning Library. NeurIPS 2019. — The paper describing PyTorch's design philosophy and dispatcher.
-
TorchDynamo: Ansel, J. et al. (2023). PyTorch 2: Faster Machine Learning Through Dynamic Python Bytecode Transformation and Graph Compilation. PLDI 2023. — The paper behind
torch.compile; explains Dynamo's bytecode approach and guard system. -
Triton: Tillet, P., Kung, H.T., Cox, D. (2019). Triton: An Intermediate Language and Compiler for Tiled Neural Network Computations. MAPL 2019. — The paper behind TorchInductor's code generation backend.
-
FlashAttention: Dao, T. et al. (2022). FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness. NeurIPS 2022. — Shows how GPU memory hierarchy analysis leads to 2-4× speedups; the same analysis applies to the warp_softmax kernel.
-
JAX: Bradbury, J. et al. (2018). JAX: Composable Transformations of Python+NumPy Programs. — JAX's
jit,grad,vmapshow an alternative design to PyTorch's; understanding both gives perspective on framework design tradeoffs. -
MLIR: Lattner, C. et al. (2021). MLIR: Scaling Compiler Infrastructure for Domain Specific Computation. CGO 2021. — The paper behind TF's MLIR compilation infrastructure.
-
XLA: Leary, C. et al. (2017). XLA: TensorFlow, Compiled. TF Dev Summit 2017. — XLA's design and its HLO IR.
Books
-
Deep Learning: Goodfellow, I., Bengio, Y., Courville, A. (2016). Deep Learning. MIT Press. — Chapters 6 (feedforward networks) and 6.5 (backpropagation) are the mathematical foundation for everything in Part I.
-
Programming Massively Parallel Processors: Kirk, D.B., Hwu, W.W. (4th ed., 2022). — The definitive textbook on CUDA programming; Chapters 4 (memory), 10 (reduction), and 15 (performance optimization) are directly relevant.
-
Matrix Computations: Golub, G.H., Van Loan, C.F. (4th ed., 2013). — The mathematical foundation for understanding why matrix operations (matmul, SVD, QR) are designed as they are.
-
What Every Computer Scientist Should Know About Floating-Point Arithmetic: Goldberg, D. (1991). ACM Computing Surveys. — The definitive reference for IEEE 754, rounding modes, and catastrophic cancellation.
Technical Documentation and Blog Posts
-
PyTorch Internals — Edward Yang's blog:
http://blog.ezyang.com/2019/05/pytorch-internals/— The most accessible deep-dive into PyTorch's C++ architecture written by one of its core developers. -
TorchDynamo Deep Dive — PyTorch Dev Discuss:
https://dev-discuss.pytorch.org/t/torchdynamo-an-experiment-in-dynamic-python-bytecode-transformation/361— The original announcement and explanation of Dynamo's bytecode approach. -
AOTAutograd Walkthrough —
https://pytorch.org/functorch/stable/notebooks/aot_autograd_optimizations.html— Official documentation with worked examples. -
CUDA Programming Guide — NVIDIA developer docs:
https://docs.nvidia.com/cuda/cuda-c-programming-guide/— The authoritative reference for warp execution, memory hierarchy, and shuffle instructions. -
torch.fx Documentation —
https://pytorch.org/docs/stable/fx.html— Official API docs with examples of graph transformation patterns. -
tf.function Guide —
https://www.tensorflow.org/guide/function— TF's official guide covering tracing, retracing, and AutoGraph in depth. -
Mixed Precision Training — Micikevicius, P. et al. (2018). Mixed Precision Training. ICLR 2018. — The paper that introduced loss scaling for FP16 training.
-
The TORCH_LIBRARY Tutorial —
https://pytorch.org/tutorials/advanced/torch_script_custom_ops.html— PyTorch's official guide to custom operator registration, with C++ examples.
Hitchhiker's Guide — PyTorch & TensorFlow Framework Internals
"You don't understand a system until you've broken it." — every production on-call engineer
This guide goes beneath the public API. It is the answer to "why does this work?" when the official docs say "just call .backward()."
1. PyTorch Execution: The Full Stack
1.1 From Python to Kernel
When you write y = torch.matmul(a, b) in Python, here is the exact call chain:
Python: torch.matmul(a, b)
→ C++: at::matmul(self, other) # aten namespace
→ c10::Dispatcher::call(op_handle, stack) # dispatch table lookup
→ Dispatch key selection: max(a.key_set(), b.key_set()) # e.g., CUDA
→ at::native::mm_cuda(self, other) # CUDA kernel dispatch
→ cublasSgemm / cublasGemmEx # vendor BLAS
The dispatcher (c10::Dispatcher) is PyTorch's universal function registry. Every operator is registered as an entry in the dispatcher table, keyed by a DispatchKey. The dispatcher picks the highest-priority key in the tensor's DispatchKeySet.
1.2 Dispatch Key Priority (highest to lowest)
Functionalize ← wraps mutations as functional ops (for torch.compile)
InferenceMode ← disables autograd
AutogradMeta ← for meta/fake tensors in torch.compile
Autograd ← wraps kernel in grad recording logic
AutocastCPU/CUDA ← AMP dtype promotion
CUDA ← GPU kernels
CPU ← CPU kernels
Meta ← shape-only "meta" device (used in compilation)
Why this matters for NPU deployment: When you register a custom NPU dispatch key (e.g., Qualcomm's QnnBackend), it must be inserted at the right priority. Below Autograd means the kernel won't record gradients — which is correct for inference-only NPU deployments.
1.3 Autograd Engine Deep Dive
Forward pass — DAG construction:
a = torch.tensor([2.0], requires_grad=True)
b = torch.tensor([3.0], requires_grad=True)
c = a * b # grad_fn = MulBackward0
d = c + a # grad_fn = AddBackward0
After these four lines, the autograd engine has built:
d (AddBackward0)
├── c (MulBackward0)
│ ├── a (leaf)
│ └── b (leaf)
└── a (leaf)
Each non-leaf tensor has a grad_fn that stores:
- The saved tensors needed for the backward pass
- References to the
next_functions(upstream nodes in the DAG)
Backward pass — Engine::execute():
The backward pass is triggered by d.backward(). The engine:
- Starts from
d.grad_fnwith initial gradienttorch.ones(1) - Calls
AccumulateGradfor leaf tensors - Calls each
grad_fn.__call__(gradient_from_downstream)in reverse topological order - Accumulates gradients via
add_into each leaf's.grad
Why topological order? A node can only compute its upstream gradient after it receives all gradients from downstream nodes that depend on it. This is Dijkstra's algorithm in reverse — we process nodes when their contribution count reaches zero.
Critical invariant: grad_fn is attached during the forward pass. The backward graph is fully determined by what ops ran in forward. This is why torch.no_grad() is a massive speedup — no Function objects are allocated.
1.4 torch.autograd.Function — The Correct Mental Model
class ClampedSoftmax(torch.autograd.Function):
@staticmethod
def forward(ctx, input, min_val, max_val):
output = torch.softmax(input, dim=-1).clamp(min_val, max_val)
ctx.save_for_backward(output) # saves tensor refs (not copies)
ctx.min_val = min_val # non-tensor metadata OK
return output
@staticmethod
def backward(ctx, grad_output):
output, = ctx.saved_tensors
# Jacobian of softmax: J_ij = o_i * (delta_ij - o_j)
# With clamping: gradient is zero where output was clamped
mask = (output > ctx.min_val) & (output < ctx.max_val)
# Softmax backward: grad_input = output * (grad_output - (grad_output * output).sum(-1, keepdim=True))
s = (grad_output * output).sum(-1, keepdim=True)
grad_input = output * (grad_output - s)
grad_input = grad_input * mask.float()
return grad_input, None, None # None for non-tensor args
Common pitfalls:
ctx.save_for_backward(x)→ storingxdirectly inctx(leaks memory, misses memory-efficient inplace handling)- Returning wrong number of gradients — must match number of forward inputs exactly (use
Nonefor non-differentiable) create_graph=True— if your backward uses non-differentiable ops, higher-order gradients will fail silently with wrong values
2. torch.fx — Symbolic Tracing Mechanics
2.1 What FX Actually Does
torch.fx.symbolic_trace(model) runs the model's forward() with Proxy objects instead of real tensors. Every operation on a Proxy records a Node in a Graph:
import torch.fx as fx
class MyModel(torch.nn.Module):
def forward(self, x):
return torch.relu(x + 1)
model = MyModel()
graph_module = fx.symbolic_trace(model)
print(graph_module.graph)
# graph():
# %x : [#users=1] = placeholder[target=x]
# %add : [#users=1] = call_function[target=operator.add](args = (%x, 1))
# %relu : [#users=1] = call_function[target=torch.relu](args = (%add,))
# return relu
The result is an FX IR — a data structure you can inspect, modify, and re-compile.
2.2 FX Graph Transformation — The Correct Pattern
class FuseConvBN(fx.Transformer):
def call_module(self, target, args, kwargs):
# Check if this is a Conv2d followed by BatchNorm2d
module = self.fetch_attr(target)
if isinstance(module, torch.nn.Conv2d):
# Look ahead in graph — check next user
node = self.current_node
if len(node.users) == 1:
next_node = list(node.users)[0]
if (next_node.op == 'call_module' and
isinstance(self.fetch_attr(next_node.target), torch.nn.BatchNorm2d)):
# Fuse: fold BN params into Conv weights
fused = torch.nn.utils.fusion.fuse_conv_bn_eval(
module, self.fetch_attr(next_node.target)
)
# Register fused module, return its output
self.root.add_module(target + '_fused', fused)
return self.tracer.call_module(target + '_fused', args, kwargs)
return super().call_module(target, args, kwargs)
2.3 FX Limitations You MUST Know for Interviews
- No data-dependent control flow:
if x.sum() > 0:cannot be traced — the Proxy doesn't know the value - No dynamic shapes by default: shapes are captured at trace time and become constants
- Closures and globals: Python closures are NOT captured in the graph — they become constants at trace time, which is a semantic footgun
torch.compilevs FX:torch.compileusesTorchDynamo(bytecode analysis) instead of symbolic tracing — it CAN handle control flow by creating multiple graphs with guards
3. torch.compile Stack
3.1 Three-Layer Architecture
User Python code
↓
TorchDynamo ← bytecode analysis, guards, graph breaks
↓ (FX Graph)
AOTAutograd ← joint forward+backward graph
↓ (FX Graph)
TorchInductor ← code generation (Triton for GPU, C++ for CPU)
↓
Compiled artifact ← .so or PTX or Metal shader
3.2 TorchDynamo — How Bytecode Analysis Works
TorchDynamo installs a frame evaluation callback that intercepts Python bytecode execution. When it encounters a function decorated with torch.compile, it:
- Disassembles the bytecode with
dis.get_instructions() - Tracks every operation that produces a tensor
- When it hits an unsupported operation (Python control flow on tensor values), it breaks the graph and falls back to Python
- For captured graph segments, it installs guards — runtime checks that verify the assumptions made during tracing are still valid
@torch.compile
def f(x):
if x.shape[0] > 10: # <- GUARD: shape[0] > 10
return x * 2
return x + 1
First call with x.shape = (15,) → compiles graph 1 (x * 2) with guard shape[0] > 10
Second call with x.shape = (5,) → guard fails → compiles graph 2 (x + 1) with guard shape[0] <= 10
Third call with x.shape = (20,) → guard 1 passes → reuses compiled graph 1
3.3 Custom Backend Registration
from torch._dynamo import register_backend
@register_backend
def my_npu_backend(gm: torch.fx.GraphModule, example_inputs):
# gm is the FX graph module
# example_inputs is a list of example tensors for shape/dtype inference
# Step 1: Optimize FX graph
gm = optimize_for_npu(gm)
# Step 2: Lower to NPU IR
npu_program = lower_to_npu_ir(gm, example_inputs)
# Step 3: Return a callable
def run(*args):
return npu_program.execute(args)
return run
model_compiled = torch.compile(model, backend="my_npu_backend")
4. TensorFlow Graph Execution Mechanics
4.1 tf.function Tracing Protocol
When Python hits a @tf.function-decorated function for the first time:
- Tracing: Python executes with
SymbolicTensorobjects (like PyTorch Proxies) - AutoGraph transformation: Python
if/for/whileare converted totf.cond/tf.while_loop - Graph construction: a
FuncGraphis built representing the TF ops - Compilation: the graph is compiled to a
ConcreteFunctionwith a specific input signature - Execution: subsequent calls with matching signatures execute the cached
ConcreteFunction
4.2 Retracing Triggers — The Complete List
@tf.function
def f(x, training=False):
return x * 2 if training else x + 1
f(tf.ones([3])) # trace 1: input_signature=(TensorSpec(shape=[3], dtype=float32),), training=False
f(tf.ones([3])) # reuse trace 1 ✓
f(tf.ones([4])) # trace 2: new shape [4] ← RETRACE (no input_signature specified)
f(tf.ones([3]), training=True) # trace 3: new Python value for training ← RETRACE
f(tf.constant(1.0)) # trace 4: scalar shape ← RETRACE
Prevention: use @tf.function(input_signature=[tf.TensorSpec(shape=[None, 128], dtype=tf.float32)]) to fix dtype/rank and allow variable batch/sequence dimensions.
4.3 tf.Variable Inside tf.function
# WRONG — creates a new Variable on every trace:
@tf.function
def bad(x):
v = tf.Variable(0.0) # ← Python-side effect, runs at trace time only!
v.assign_add(x.sum())
return v
# CORRECT — Variable must be created outside tf.function:
v = tf.Variable(0.0)
@tf.function
def good(x):
v.assign_add(x.sum())
return v
5. Numerical Precision Deep Dive
5.1 FP32 vs BF16 vs FP16
| Format | Sign | Exponent | Mantissa | Range | Precision |
|---|---|---|---|---|---|
| FP32 | 1 | 8 | 23 | ±3.4e38 | ~7 decimal digits |
| FP16 | 1 | 5 | 10 | ±65504 | ~3.3 decimal digits |
| BF16 | 1 | 8 | 7 | ±3.4e38 | ~2.3 decimal digits |
Key insight for NPU work: BF16 has the same dynamic range as FP32 (same exponent bits) but lower precision. This makes BF16 safer for training (no overflow/underflow) but potentially less accurate for narrow distribution outputs. Qualcomm's HTP natively supports INT8/INT16/FP16/BF16 — knowing which precision per-layer gives the best accuracy-performance tradeoff is core to this role.
5.2 Catastrophic Cancellation and Kahan Summation
Summing [1e8, 1.0, -1e8] in FP32:
- Naive:
(1e8 + 1.0) = 1e8(1.0 is below precision of 1e8 in FP32), then1e8 - 1e8 = 0.0← WRONG - Kahan: maintains a running compensation term, gets
1.0← CORRECT
def kahan_sum(values):
total = 0.0
compensation = 0.0
for v in values:
y = v - compensation
t = total + y
compensation = (t - total) - y
total = t
return total
Interview answer: "Why does your softmax accumulate error in FP16?" → explain that the normalization sum in softmax has large magnitude (sum of exps), and the numerator single-exp is small — catastrophic cancellation. Fix: compute in FP32, cast output to FP16.
6. Common Interview Pitfalls
| Question | Wrong Answer | Correct Answer |
|---|---|---|
| "How does autograd build the graph?" | "It runs the forward pass twice" | "It records ops during the single forward pass using Function objects attached to tensors as grad_fn" |
"What does detach() do?" | "It deletes the gradient" | "It creates a new tensor sharing storage but with no grad_fn, cutting the gradient tape at that point" |
"When should you use retain_graph=True?" | "Always, to be safe" | "Only when you need to call backward() multiple times on the same graph — e.g., for higher-order gradients or multiple loss terms with shared computation" |
| "What is the Meta device?" | "A CPU variant" | "A device where tensors have shapes and dtypes but no actual data — used by torch.compile for shape propagation without running kernels" |
"What does torch.compile break on?" | "Everything dynamic" | "Specifically: data-dependent control flow, dynamic shapes (by default), Python side effects, calls into non-PyTorch code — each triggers a graph break" |
7. Resources & Further Reading
- PyTorch Internals Blog — Edward Yang
- TorchDynamo — How it Works (PyTorch Dev Blog)
- Autograd: Automatic Differentiation in ML (JMLR 2017) — the paper behind modern autodiff engines
- TF
tf.functionGuide - PyTorch FX API Docs
- AOTAutograd internals
- Paper to read: "JAX: Composable Transformations of Python+NumPy Programs" (Bradbury et al., 2018) — JAX solves the same problems as torch.compile but differently; understanding both is a superpower
👨🏻 Brother Talk — Phase 01, Off the Record
Framework internals — the phase that decides whether you're a framework user or a framework engineer. At Qualcomm Senior Staff level, only the second one gets hired.
Brother, let me tell you the single thing that separates the people who pass this interview
loop from the people who don't, because it took me too long to see it: almost everyone can
use PyTorch, and almost nobody can explain it. They can write loss.backward() a
thousand times and have no idea what happens after they hit enter. And the Qualcomm Senior
Staff role is specifically about what happens after you hit enter — the dispatcher, the
autograd graph, the kernel that actually runs. You are being hired to work below the API
that everyone else lives on top of. So this phase isn't "review PyTorch." It's "stop being a
tourist in the framework you've used for years."
Here's the mental reframe that unlocks it. PyTorch is not one thing — it's three layers
pretending to be one: the Python eager frontend (what you type), the C++ dispatcher (the
c10::Dispatcher that routes every op by dispatch key — CPU, CUDA, Autograd, Functionalize,
Meta), and the backend kernels (the code that does the math). Every bug you'll ever debug at
this job lives at a boundary between these layers: a custom op that works eager but breaks
under torch.compile (boundary: dispatcher ↔ Dynamo), a quantized model that NaNs after a
graph pass (boundary: Autograd ↔ Functionalize), a TF model that gives different answers in
graph mode (boundary: eager ↔ tf.function tracing). If you can name the layer a bug lives
in, you've already done 80% of the debugging. Build the autograd engine by hand in Lab 01 and
this stops being abstract — you'll feel the graph being constructed on the forward pass and
walked on the backward.
The thing nobody tells you about the autograd engine: it's simpler than you fear and more
beautiful than you expect. It's just a DAG built during forward (each op records how to
compute its gradient), walked in reverse-topological order during backward, accumulating
gradients at the leaves. That's it. Once you've written Engine::execute() in miniature —
even in pure Python — you'll never again be intimidated by "how does PyTorch know the
gradient?" And you'll be the person in the room who can explain retain_graph, create_graph
for double-backward, and why AccumulateGrad exists, while everyone else mumbles. That clarity
is worth the week it takes.
Now the custom-operator stuff (Labs 02), because this is where the Qualcomm job actually
lives. When you write a custom op — say, an NPU-accelerated kernel — it has to behave correctly
under every dispatch key: CPU, CUDA, autograd (does it have a gradient?), functionalization
(is it functional or does it mutate?), torch.compile (can Dynamo trace through it?). The
classic disaster: someone registers a custom op that works in eager, ships it, and three months
later it silently produces garbage under torch.compile because they never registered a Meta
kernel or a functionalization rule. That is the bug you're being hired to prevent. The C++
operator lab feels like plumbing; it's actually the core skill — writing ops that are safe
under all the ways PyTorch can call them. (And yes, the lab may skip on a machine without a
C++ toolchain — that's expected; read the code, understand the TORCH_LIBRARY schema, build it
for real when you have a compiler.)
On TensorFlow: don't skip it because PyTorch won. Qualcomm ships both, and the tf.function
tracing model is a gold mine of interview questions precisely because it's subtle. The trap
everyone falls into: Python side-effects inside a tf.function run once (at trace time), not
every call, and retracing triggers on new input signatures. The "model gives different results
in graph mode" bug is almost always a Python int where a tf.Variable belonged, or a closure
capturing a stale value. Understand tracing-vs-execution once and you'll debug a class of bugs
that mystifies whole teams.
A word on numerical precision, because it's the quiet thread through this entire role: FP32 vs FP16 vs BF16 is not just "smaller is faster." It's where you accumulate, what cancels, and when you get NaN. BF16 has FP32's exponent range with fewer mantissa bits — great for training stability, lossy for precision. The day you debug a model that's fine in FP32 and NaNs in FP16, you'll be grateful you understand catastrophic cancellation and why accumulation dtype matters. This thread runs straight into quantization (P03) and accuracy (P09) — it's the same fight at lower and lower precision.
Career truth, brother: this phase is the foundation that makes every later phase possible,
and it's also the most legible signal of seniority in an interview. When you can trace
loss.backward() to the kernel, explain dispatch keys, and write a custom op that's safe under
compile — the interviewer knows you've operated below the API. That credibility carries the
whole loop. Spend the time here. Build the autograd engine by hand. It's the difference between
"I know PyTorch" and "I know how PyTorch works," and only the second sentence gets the Senior
Staff offer.
Build the labs. Trace one op end to end. Then come to P02, where we go up a level to the architectures — and you'll understand them more deeply because you know what they compile down to.
— your brother 👨🏻
Lab 01 — Custom Autograd Engine
Phase: 01 — PyTorch & TF Framework Internals | Difficulty: ⭐⭐⭐☆☆ | Time: 2–3 hours
Read
../HITCHHIKERS-GUIDE.mdbefore starting. This lab builds intuition for how PyTorch computes gradients under the hood.
What you build
A minimal scalar-valued autograd engine that mirrors PyTorch's torch.Tensor autograd mechanism:
Valueclass wrapping scalars with+,*,tanh,relu,exp,powoperations- Reverse-mode automatic differentiation via
.backward() - Dynamic computation graph construction (define-by-run)
- Gradient accumulation across shared nodes (topological sort)
Key concepts
| Concept | What to understand |
|---|---|
| Computation graph | Each op creates a node; edges are _prev set |
| Chain rule | dL/dx = dL/dout * dout/dx applied backwards |
| STE | How gradients skip through non-differentiable ops |
detach() | Stops gradient flow — critical for quantization |
Files
| File | Purpose |
|---|---|
lab.py | Stub with TODO markers — implement this |
solution.py | Complete working implementation |
test_lab.py | pytest suite — run to verify your work |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py # should print results after you fill in TODOs
pytest test_lab.py -v
Lab 02 — Custom C++ Operator
Phase: 01 — PyTorch & TF Framework Internals | Difficulty: ⭐⭐⭐⭐☆ | Time: 2–4 hours
Read
../HITCHHIKERS-GUIDE.mdbefore starting.
What you build
A custom PyTorch operator written in C++ that registers with the dispatcher:
- C++ extension using
pybind11andtorch/extension.h - Forward pass: fused linear + ReLU in a single C++ kernel
- Registration via
TORCH_LIBRARY/PYBIND11_MODULE - Python test verifying numeric equality with the PyTorch reference
Key concepts
| Concept | What to understand |
|---|---|
| ATen dispatcher | How torch.ops.mylib.op() routes to C++ |
at::Tensor | C++ tensor API — same data, different language |
setup.py + torch.utils.cpp_extension | Build system for extensions |
| Schema | The type signature registered with the dispatcher |
Files
| File | Purpose |
|---|---|
setup.py | Build configuration for C++ extension |
csrc/ | C++ source files |
solution.py | Python tests and usage |
requirements.txt | Python dependencies |
Run
pip install -r requirements.txt
python setup.py build_ext --inplace
python solution.py
Lab 03 — TF tf.function Tracing & Graph Analysis
Phase: 01 — PyTorch & TF Framework Internals | Difficulty: ⭐⭐⭐⭐☆ | Time: 3–4 hours
tf.functionis the boundary between eager Python and TensorFlow's graph runtime. Every "the model behaves differently in graph mode" bug lives at this boundary. This lab makes the tracing machinery visible.
What you build
trace_and_inspect— capture theConcreteFunctionfor given inputs and produce aGraphReport: node counts by op type, captured external tensors, structured input signatureRetraceProbe— a wrapper that counts how many times Python tracing actually runs, used to demonstrate (and then prevent) the classic retracing triggersstabilize— apply an explicitinput_signatureso shape/value changes reuse one traceautograph_diff— show the AutoGraph-transformed source of a Python function withif/forcontrol flow (what your Python actually becomes)roundtrip_savedmodel— save → reload → verify outputs match exactlyconvert_tflite_dynamic— TFLite dynamic-range quantization with max-abs output delta vs the TF model on random probe inputs
Key concepts
| Concept | What to understand |
|---|---|
| Trace vs execute | Python body runs ONCE per signature (trace time); the graph runs every call |
| Retracing triggers | New dtype, new rank/shape (without signature), new Python value, new closure object |
| Python side effects | print(), list appends, counters fire at trace time only — a top-3 production bug source |
input_signature | Pins the trace to symbolic shapes — None dims accept any size with zero retraces |
| ConcreteFunction | A traced graph + signature; fn.get_concrete_function(...) exposes the FuncGraph |
| AutoGraph | Rewrites Python if/while into tf.cond/tf.while_loop when operands are tensors |
| TFLite dynamic-range | Weights stored INT8, activations computed FP32 — cheapest quantization, first accuracy checkpoint |
Files
| File | Purpose |
|---|---|
lab.py | Skeleton with # TODO markers — implement each utility |
solution.py | Complete reference implementation with commentary |
test_lab.py | Pytest suite validating retrace counts, graph reports, SavedModel/TFLite round-trips |
requirements.txt | tensorflow>=2.15, numpy, pytest |
Run
pip install -r requirements.txt
pytest test_lab.py -v # validate your lab.py
python solution.py # run the full demo: retrace report + TFLite delta
Suggested TODO order
trace_and_inspect— everything else builds on being able to see aFuncGraphRetraceProbe— prove to yourself exactly which calls retrace and whystabilize— make the retraces stop with aninput_signatureautograph_diff— look at what AutoGraph did to yourifstatementroundtrip_savedmodel, thenconvert_tflite_dynamic
Success criteria
- You can enumerate the retracing triggers from memory and predict the retrace count of a call sequence before running it
- You can explain why a
print()insidetf.functionfires once buttf.printfires every call - Your TFLite dynamic-range conversion shows max output delta < 1e-2 on the small MLP (weights-only quantization on a well-conditioned model is nearly lossless)
Interview Q&A
Q: When does tf.function retrace?
New input dtype; new rank or (without a signature) new concrete shape; a new Python-native
argument value (every distinct Python int/str is a new signature!); a new closure/object
identity for weakly-referenced captures; calling with kwargs vs args inconsistently.
Q: Why did my counter only increment once even though I call the function in a loop?
The increment is a Python side effect — it runs at trace time. The traced graph contains
only TF ops. Use tf.Variable.assign_add to make mutation part of the graph.
Q: Why is passing a Python int as batch_size a performance bug?
Each distinct int is a different trace signature → unbounded retracing. Pass it as a
tf.Tensor or pin an input_signature with tf.TensorSpec([None, ...]).
Q: When is TFLite conversion not lossless?
Dynamic-range quantization perturbs weights (INT8); ops without TFLite kernels get rewritten
(e.g., erf-based GELU → tanh approximation); fused kernels may use different accumulation
order; resource variables get frozen to constants.
Extensions
- Repeat the analysis on BERT-tiny and evaluate the MNLI accuracy delta after dynamic-range quantization (the full-scale version of this lab, see phase README)
- Inspect the XLA HLO with
tf.function(jit_compile=True)andfn.experimental_get_compiler_ir(...)('hlo') - Profile trace time vs execution time with the TF Profiler
References
- tf.function guide — the retracing rules, authoritative
- Introduction to graphs and tf.function
- AutoGraph reference
- TFLite post-training quantization
Phase 02 — Model Architecture Mastery Across Modalities
Difficulty: ⭐⭐⭐⭐☆
Estimated Time: 3 weeks (60–80 hours)
Roles Supported: All Senior Staff ML roles — architecture expertise underpins every optimization decision
Why This Phase Exists
You cannot optimize what you don't understand. Before a Senior Staff engineer at Qualcomm can ask "why does this model's accuracy degrade more under INT4 quantization than that one?", they need to know: GQA uses fewer KV heads → fewer unique weight rows → friendlier to per-group quantization. MoE models have sparse activation → different quantization strategies per expert. SSMs (Mamba) have recurrent structure → completely different memory access patterns than attention.
The JD requires "expertise in model architecture, backed by literature rationale and intuition" and "understanding of broad-spectrum model architectures across modalities." This phase builds that expertise through implementation — you will implement every major architectural variant that appears in production models (LLaMA, Mistral, Mixtral, ViT, DINO, LLaVA, Mamba) from scratch or near-scratch.
After this phase, when asked in an interview "how would you adapt your NPU tiling strategy for a Mixture-of-Experts model?", you can answer specifically because you understand the routing mechanism and its sparsity implications.
Concepts
- Scaled Dot-Product Attention (SDPA): $\text{Attention}(Q,K,V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V$ — computational complexity $O(n^2 d)$, memory $O(n^2)$
- Multi-Head Attention (MHA): split $d_{model}$ into $h$ heads, concatenate outputs — fully parallelizable across heads
- Grouped Query Attention (GQA): $g$ query groups share one KV head pair — reduces KV cache size by factor $h/g$; used in LLaMA-3, Mistral
- Multi-Query Attention (MQA): extreme GQA with $g=1$ — single KV head, all query heads; MQA = GQA with groups=1
- Rotary Position Embeddings (RoPE): encode position via rotation of Q,K vectors in complex plane: $f_q(x_m) = R^m_d x_m$ — length extrapolation via RoPE scaling (LLaMA)
- ALiBi (Attention with Linear Biases): position bias added to attention logits as $-m \cdot |i-j|$ — train short, generalize long
- SwiGLU / GeGLU: gated feedforward: $\text{FFN}(x) = (\sigma(W_1 x) \odot W_2 x) W_3$ — used in PaLM, LLaMA; ~25% more params but better performance per FLOP
- Pre-norm vs Post-norm: pre-norm (RMSNorm before attention) = more stable training; used in all modern LLMs
- RMSNorm: $\hat{x} = \frac{x}{\text{RMS}(x)} \cdot \gamma$ where $\text{RMS}(x) = \sqrt{\frac{1}{n}\sum x_i^2}$ — no mean subtraction, faster than LayerNorm
- Mixture of Experts (MoE): sparse activation — each token routes to top-K of E experts; active params per token = dense_params + (K/E × expert_params)
- Vision Transformer (ViT): divide image into non-overlapping patches → linear projection → standard transformer; patch size controls trade-off between sequence length and feature resolution
- DINO / DINOv2: self-supervised ViT — student-teacher distillation with no labels; produces strong dense features for segmentation/detection
- CLIP: contrastive vision-language pretraining — align image and text embeddings via InfoNCE loss; zero-shot classification and image-text retrieval
- LLaVA-style multimodal: visual tokens from CLIP vision encoder → linear projection → prepend to LLM token sequence → standard LLM forward
- Mamba / SSMs (Structured State Space Models): recurrent models with selective state transitions; linear time in sequence length; competitive with transformers on long sequences
Labs
Lab 01 — Transformer with Full Architectural Variants
| Field | Value |
|---|---|
| Goal | Implement a decoder-only transformer where attention type (MHA/GQA/MQA), position encoding (RoPE/ALiBi/learned), FFN type (standard/SwiGLU/GeGLU), and normalization (LayerNorm/RMSNorm) are all swappable config options; benchmark perplexity on Wikitext-103 |
| Concepts | GQA/MQA/MHA, RoPE, ALiBi, SwiGLU, RMSNorm, pre-norm, KV cache, causal masking |
| Steps | 1. Implement CausalSelfAttention supporting MHA, GQA, MQA via config; 2. Implement RoPE and ALiBi position encodings; 3. Implement SwiGLU and GeGLU FFN variants; 4. Implement RMSNorm; 5. Assemble configurable TransformerBlock and GPTModel; 6. Implement KV cache for autoregressive inference; 7. Benchmark perplexity on Wikitext-103 for 5 architecture configs |
| Stack | PyTorch 2.3+, datasets (HuggingFace), tiktoken |
| Datasets | Wikitext-103 (datasets.load_dataset("wikitext", "wikitext-103-v1")) |
| Output | Model configurations A–E with perplexity table; KV cache correctness test (cached vs non-cached outputs match); GFlops comparison across configs |
| How to Test | python test_lab.py — validates: (1) GQA output matches naive expansion within 1e-5, (2) RoPE rotation is length-generalizable, (3) KV cache produces identical output to full attention, (4) perplexity on 100-token eval decreases with larger model |
| Talking Points | Why does GQA reduce KV cache memory? How does RoPE enable position extrapolation? When would you prefer ALiBi over RoPE? What is the FLOP count of SwiGLU vs standard FFN? |
| Resume Bullet | Implemented configurable decoder-only transformer with GQA/RoPE/SwiGLU variants from scratch; benchmarked 5 architecture configs on Wikitext-103, demonstrating 23% perplexity improvement from architectural choices alone |
| Extensions | Add sliding window attention (Mistral); implement expert routing for MoE; add Flash-attention-compatible causal mask |
Lab 02 — Mixture of Experts (MoE)
| Field | Value |
|---|---|
| Goal | Implement the MoE layer used in Mixtral and Switch Transformer: top-K routing, auxiliary load-balancing loss, and expert-utilization diagnostics |
| Concepts | Conditional compute, TopKRouter, Switch Transformer aux loss $n_{experts}\sum_i f_i p_i$, expert collapse, top-1 vs top-2 routing |
| Steps | 1. Implement TopKRouter (linear routing + top-K selection + aux loss); 2. Implement ExpertLayer (w1 → GELU → w2); 3. Implement MoEBlock combining weighted expert outputs per token; 4. Implement compute_expert_utilization to detect load imbalance; 5. Verify aux loss pushes utilization toward uniform |
| Stack | PyTorch 2.3+ |
| Output | Working MoE block with passing tests; expert-utilization report showing balanced routing under aux loss |
| How to Test | pytest test_lab.py — validates routing shapes, aux loss formula, top-K combination weights summing to 1, utilization balance |
| Talking Points | Why does expert collapse happen and how does the aux loss prevent it? Why does Mixtral use top-2 instead of top-1? What is the quantization implication of sparse expert activation? |
| Resume Bullet | Implemented Mixtral-style MoE layer with Switch Transformer load balancing from scratch; diagnosed and prevented expert collapse via auxiliary loss and utilization monitoring |
| Extensions | Add expert-parallel sharding plan; implement capacity factor with token dropping; benchmark dense-equivalent FLOPs vs active FLOPs |
→ Lab folder: lab-02-mixture-of-experts/
Lab 03 — State Space Models (S4 / Mamba)
| Field | Value |
|---|---|
| Goal | Implement the SSM recurrence, ZOH discretization, and Mamba's selective scan; demonstrate O(N) memory vs O(N²) attention |
| Concepts | SSM state equation $h_t = \bar{A}h_{t-1} + \bar{B}x_t$, Zero-Order Hold discretization, input-dependent (selective) $B, C, \Delta$, linear recurrence, hardware-efficient parallel scan |
| Steps | 1. Implement discretize_zoh ($\bar{A} = e^{\Delta A}$, $\bar{B} = (\bar{A}-I)A^{-1}B$); 2. Implement s4_recurrence sequential scan; 3. Implement SelectiveSSM with input-dependent parameters; 4. Assemble MambaBlock (in_proj + depthwise conv + SSM + gating); 5. Implement compare_memory_usage quantifying attention vs SSM memory |
| Stack | PyTorch 2.3+ |
| Output | Working Mamba block with passing tests; memory comparison table attention vs SSM across sequence lengths |
| How to Test | pytest test_lab.py — validates ZOH math, recurrence outputs, selective parameter shapes, memory scaling claim |
| Talking Points | Why is selectivity (input-dependent B/C/Δ) the key difference from S4? Why are SSMs attractive for edge/NPU long-context inference? What breaks when you quantize a recurrent state? |
| Resume Bullet | Implemented Mamba selective state space model from scratch including ZOH discretization and selective scan; demonstrated linear-memory long-context inference vs quadratic attention |
| Extensions | Implement parallel associative scan; add Mamba-2 SSD formulation; hybrid attention+SSM block (Jamba-style) |
→ Lab folder: lab-03-state-space-models/
Lab 04 — ViT + DINO Self-Distillation
Implemented at testable scale in lab-04-vit-dino/ — ViT from scratch, DINO loss with centering+sharpening, momentum teacher, multi-crop step, 13-test suite that runs on synthetic data in seconds. The full CIFAR-10 training run described below is the lab's extension target.
| Field | Value |
|---|---|
| Goal | Implement ViT from scratch and replicate the DINO self-supervised training loop (student-teacher with centering + sharpening); validate that attention maps show semantic segmentation behavior |
| Concepts | Patch embedding, positional embedding, class token, multi-head self-attention, DINO momentum teacher, InfoNCE/cross-entropy with temperature, centering, attention map visualization |
| Steps | 1. Implement PatchEmbed (conv2d with stride=patch_size); 2. Implement ViT encoder (standard; use Lab 01 attention); 3. Implement DINO loss: student-teacher cross-entropy with softmax + centering + sharpening; 4. Implement momentum update for teacher; 5. Train on CIFAR-10 for 30 epochs (1h on single GPU); 6. Visualize last-layer attention heads — they should segment objects |
| Stack | PyTorch 2.3+, torchvision, matplotlib |
| Datasets | CIFAR-10 (via torchvision); optional: STL-10 for better quality |
| Output | Trained ViT-Tiny/CIFAR10; attention map visualizations showing semantic segmentation; linear probe accuracy ≥80% on CIFAR-10 without labels |
| How to Test | python test_lab.py — checks: patch embedding output shape, CLS token aggregation, DINO loss decreasing monotonically over first 100 steps, attention entropy decreasing (heads specializing) |
| Talking Points | Why does the teacher use momentum update instead of gradient update? What does the centering operation prevent? Why do ViT attention heads naturally segment objects without segmentation supervision? |
| Resume Bullet | Implemented ViT + DINO self-supervised training from scratch; achieved 82% linear-probe accuracy on CIFAR-10 with no labels; visualized emergent semantic segmentation in attention maps |
| Extensions | Replace patch embedding with DeiT-style data augmentation; add register tokens (DINOv2); implement dense feature extraction for downstream segmentation |
Lab 05 — Multimodal Bridge (CLIP → LLM Projection)
Implemented at testable scale in lab-05-multimodal-bridge/ — trainable MLP projector between a frozen vision tower and a frozen causal LM, with the loss masking and causal-shift logic identical to LLaVA's, 12-test suite, no downloads. The full CLIP + TinyLlama + LLaVA-Instruct version below is the extension target.
| Field | Value |
|---|---|
| Goal | Build a LLaVA-style multimodal model: freeze a CLIP vision encoder, train a MLP projection that maps visual tokens to LLM embedding space, and demonstrate image-conditioned text generation |
| Concepts | CLIP ViT-L/14 feature extraction, projection MLP, visual token prepending, instruction fine-tuning format, frozen vs unfrozen components, multimodal loss masking |
| Steps | 1. Load openai/clip-vit-large-patch14 from HuggingFace (frozen); 2. Extract image features (patch tokens, not just CLS); 3. Implement 2-layer MLP projector with GELU; 4. Load a small LLM (e.g., TinyLlama-1.1B) and freeze; 5. Implement MultimodalModel.forward() that prepends image tokens before text; 6. Fine-tune projector only on LLaVA-Instruct-150K (image caption subset); 7. Evaluate on VQAv2 using accuracy metric |
| Stack | PyTorch 2.3+, transformers, datasets, Pillow |
| Datasets | LLaVA-Instruct-150K (image subset, ~50k samples); VQAv2 val (for evaluation) |
| Output | Trained projector weights; VQAv2 validation accuracy (target: >50%); qualitative demos of image Q&A |
| How to Test | python test_lab.py — checks: visual token dimension matches LLM embedding dim, attention mask correctly excludes image tokens from loss computation, generation outputs valid text for 5 sample images |
| Talking Points | Why freeze the vision encoder and LLM during projector training? What are the trade-offs of training the full model vs projector-only? How would you scale this to a 70B LLM on a Qualcomm NPU? |
| Resume Bullet | Built LLaVA-style multimodal bridge (CLIP→LLM projection); trained MLP projector on 50K instruction pairs; achieved 54% VQAv2 accuracy with only projector parameters fine-tuned (0.1% of total params) |
| Extensions | Add Q-Former connector (BLIP-2 style); implement visual grounding via region features; add video token temporal pooling |
Deliverables Checklist
- Configurable transformer with 5 tested architecture configs and perplexity table
- MoE block with balanced expert utilization under aux loss
- Mamba block with ZOH discretization and selective scan, memory comparison vs attention
- ViT + DINO lab passing (13 tests); (stretch) CIFAR-10 run with attention map visualizations
- Multimodal bridge lab passing (12 tests); (stretch) CLIP+TinyLlama with VQAv2 score
-
All
test_lab.pysuites pass
Guides in This Phase
- HITCHHIKERS-GUIDE.md — attention math, ViT, MoE, Mamba overview, interview numbers
- DEEP-DIVE-MOE-SSM.md — Mixture of Experts and State Space Models from first principles (supports Labs 02–03)
Interview Relevance
- "What is the memory advantage of GQA over MHA at 32K context?" — you can compute: (h×2×n×d) vs (h/g × 2×n×d), show 8x reduction for g=8
- "How does DINO work without labels?" — explain centering + sharpening, why teacher EMA is key, why augmentation multi-crop works
- "What is the bottleneck in deploying LLaVA on an NPU?" — you understand the KV cache growth with visual tokens, the projection cost, and the LLM decode bottleneck
- "Why does RoPE generalize to longer sequences than learned position embeddings?" — explain the frequency decomposition and length-independent rotation property
Warmup Guide — Model Architecture Mastery
Zero-to-expert primer for Phase 02. Read this before the labs: it builds every concept from first principles — attention, position encodings, normalization, Mixture of Experts, State Space Models, Vision Transformers, and multimodal bridges — assuming only basic Python and linear algebra.
Table of Contents
- Chapter 1: Why Architecture Knowledge Is an Optimization Skill
- Chapter 2: Attention from Zero
- Chapter 3: The KV Cache — Where Inference Cost Lives
- Chapter 4: MHA, MQA, GQA — The KV-Head Spectrum
- Chapter 5: Position Encodings — Learned, RoPE, ALiBi
- Chapter 6: Normalization and FFN Variants
- Chapter 7: Mixture of Experts
- Chapter 8: State Space Models and Mamba
- Chapter 9: Vision Transformers and DINO
- Chapter 10: Multimodal Bridges
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Why Architecture Knowledge Is an Optimization Skill
Zero background: a neural network architecture is the fixed computational skeleton — which matrices multiply which activations in what order. Weights change during training; the architecture doesn't.
Why it matters for this curriculum: every optimization decision downstream — which layers to quantize to INT4, how to tile a matmul for an NPU, why accuracy collapses after a graph transformation — is a function of architectural structure. Three concrete examples:
- GQA shares KV heads → fewer unique weight rows → per-group quantization has fewer groups to calibrate → quantizes more gracefully than MHA.
- MoE activates 2 of 8 experts per token → 75% of expert weights are cold per token → weight streaming and per-expert quantization become viable; but the router is tiny and precision-sensitive — quantize it last.
- Mamba carries a recurrent state → no KV cache, O(1) memory per token — but the state is numerically delicate; INT8-quantizing the recurrence accumulates error step by step in a way attention never does.
Misconception to kill now: "architectures are interchangeable backends for the same function." The function may be similar; the hardware cost surface is wildly different, and that surface is your job.
Chapter 2: Attention from Zero
The problem attention solves: process a sequence so each position can use information from any other position, with content-dependent (not fixed) routing.
Mechanism, from nothing: each token's embedding $x_i \in \mathbb{R}^d$ produces three vectors via learned projections: a query $q_i = W_Q x_i$, a key $k_i = W_K x_i$, a value $v_i = W_V x_i$. Token $i$ scores every token $j$ by dot product $q_i \cdot k_j$ — "how relevant is $j$ to me" — then turns scores into a probability distribution and takes the weighted average of values:
$$\text{Attention}(Q,K,V) = \text{softmax}!\left(\frac{QK^\top}{\sqrt{d_k}}\right)V$$
Every term, justified:
- $QK^\top$ is an $n \times n$ matrix of all pairwise scores — this is where the $O(n^2)$ cost in both compute and memory comes from.
- $\sqrt{d_k}$: dot products of random $d_k$-dim vectors have variance $\propto d_k$; unscaled, softmax inputs grow with dimension, saturating it (gradients vanish). Dividing by $\sqrt{d_k}$ keeps variance ≈ 1 regardless of head size.
- softmax row-wise: each query gets a distribution over keys, so the output is a convex combination of values — bounded, differentiable routing.
- Causal masking (decoders): set scores $j > i$ to $-\infty$ before softmax, so position $i$ cannot see the future. After softmax those entries are exactly 0.
Multi-head: instead of one attention in $\mathbb{R}^d$, split into $h$ heads of dimension $d/h$, attend independently, concatenate, project. Heads learn different relation types (syntax, coreference, position). Cost is the same FLOPs as single-head at full width; the win is representational.
Production significance: the $n^2$ attention matrix is the reason long-context is hard, the reason FlashAttention exists (Phase 08), and the reason KV caching dominates inference memory (next chapter).
Chapter 3: The KV Cache — Where Inference Cost Lives
The setup: autoregressive generation produces one token at a time. Naively, generating token $t+1$ recomputes attention over all $t$ previous tokens from scratch — $O(t^2)$ work per token, $O(T^3)$ total.
The fix: $K$ and $V$ for past tokens never change (causality). Cache them. Each new token computes only its own $q, k, v$, appends $k, v$ to the cache, and attends: $O(t)$ per token.
The cost you bought: memory. Per token, per layer: $2 \times h_{kv} \times d_{head}$ values. For LLaMA-2-7B (32 layers, 32 KV heads, head dim 128, FP16):
$$32 \times 2 \times 32 \times 128 \times 2,\text{bytes} = 512,\text{KB per token}$$
A 4K-token context costs 2 GB — for batch size 1. This single number explains: why GQA exists (Chapter 4), why batch sizes are memory-bound, why PagedAttention manages KV like virtual memory (Phase 08), and why edge deployment quantizes the KV cache itself.
Misconception: "inference cost = model weights." For long contexts and large batches, KV cache rivals or exceeds weight memory. Always compute both.
Chapter 4: MHA, MQA, GQA — The KV-Head Spectrum
One design axis: how many KV head pairs do $h$ query heads share?
| Variant | KV heads | KV cache vs MHA | Quality |
|---|---|---|---|
| MHA | $h$ (one per query head) | 1× | best |
| GQA | $g$ groups ($1 < g < h$) | $g/h$ (e.g. ⅛ for LLaMA-3) | ≈MHA with modest training |
| MQA | 1 | $1/h$ | measurable degradation |
Mechanism: in GQA, query heads within a group attend using the same $K, V$
projections. Implementation is just repeat_interleave of KV heads to match query heads
(or better: a broadcasted attention kernel that never materializes the repetition).
Why this is the consensus design (LLaMA-3, Mistral, Qwen): KV cache (Chapter 3) is the inference bottleneck; GQA cuts it 4–8× for ~zero quality loss. It also helps quantization: fewer distinct KV projections → fewer calibration distributions.
Lab 01 implements all three behind one config flag — the test checks GQA output matches the naive repeat-expansion within 1e-5, which forces you to get the grouping exactly right.
Chapter 5: Position Encodings — Learned, RoPE, ALiBi
The problem: attention is permutation-invariant — shuffle the tokens and the scores are identical. Order must be injected.
Learned absolute embeddings (GPT-2): a trainable vector per position, added to token embeddings. Simple; cannot extrapolate past training length (position 2049 was never trained); positions don't compose ("distance 5" isn't represented).
RoPE (Rotary Position Embeddings) — used by LLaMA, Qwen, most modern LLMs. Idea: encode position as rotation. Pair up dimensions of $q$ and $k$; rotate each pair $(x_1, x_2)$ at position $m$ by angle $m\theta_i$, with per-pair frequencies $\theta_i = 10000^{-2i/d}$:
$$\begin{pmatrix} \cos m\theta_i & -\sin m\theta_i \ \sin m\theta_i & \cos m\theta_i \end{pmatrix}\begin{pmatrix} x_{2i} \ x_{2i+1} \end{pmatrix}$$
The magic: a rotation by $m\theta$ on $q$ and $n\theta$ on $k$ leaves their dot product depending only on $m - n$ — relative position falls out of absolute rotations. Multi-frequency = a "clock" with many hands: fast hands resolve nearby distances, slow hands resolve far ones. Extrapolation beyond training length works partially and is extended by position interpolation / NTK-aware scaling (scaling $\theta$ at inference).
ALiBi: skip embeddings entirely; subtract a per-head linear penalty $m_h \cdot |i - j|$ from attention logits. Recency bias, baked in. Trains short, generalizes long remarkably well; but no notion of position beyond distance, and weaker at tasks needing precise absolute positions.
Choosing: RoPE is the default; ALiBi when train-short-infer-long matters most. Lab 01's test checks RoPE's relative-position property explicitly.
Chapter 6: Normalization and FFN Variants
LayerNorm from zero: deep nets suffer when activation scales drift across layers. LayerNorm re-standardizes each token's vector: subtract its mean, divide by its std, then apply learned scale/shift. RMSNorm drops the mean subtraction: $\hat{x} = x / \sqrt{\tfrac{1}{d}\sum x_i^2} \cdot \gamma$ — one fewer reduction, ~10-15% faster, and empirically equivalent in transformers. All modern LLMs use RMSNorm.
Pre-norm vs post-norm: normalize before the sublayer (pre-norm, modern) and the residual path stays an identity highway — gradients flow unimpeded, training is stable without warmup tricks. Post-norm (original Transformer) normalizes the sum, which can amplify or attenuate the residual signal — deeper stacks destabilize.
SwiGLU: standard FFN is $W_2,\sigma(W_1 x)$. Gated variants compute $W_3(\sigma(W_1x) \odot W_2x)$ — one path gates the other multiplicatively, letting the FFN model input-dependent feature selection. ~25% more parameters per FFN at the same hidden dim (three matrices, not two); in practice hidden dim is shrunk by ⅔ to compensate. PaLM/LLaMA measurements: better loss per FLOP. SiLU ($x\sigma(x)$) is the usual $\sigma$.
Quantization note: the elementwise multiply in SwiGLU creates wider activation dynamic range than ReLU FFNs — one reason LLM activations are harder to quantize than CNN activations (foreshadowing Phase 03's SmoothQuant).
Chapter 7: Mixture of Experts
Zero background: a dense FFN applies the same weights to every token. MoE replaces one FFN with $E$ parallel FFNs ("experts") plus a tiny router that picks $K$ experts per token (K=1 Switch, K=2 Mixtral). Total parameters scale with $E$; compute per token scales with $K$ — parameters and FLOPs are decoupled.
Router mechanics: logits $= W_r x$ (an $E$-way linear classifier per token); take top-K; softmax over the selected logits gives combination weights; output = weighted sum of the chosen experts' outputs.
The failure mode — expert collapse: routing is self-reinforcing. An expert that gets more tokens trains more, improves, attracts more tokens; others atrophy. End state: one expert does everything and you paid for $E$.
The fix — auxiliary load-balancing loss (Switch Transformer): with $f_i$ = fraction of tokens routed to expert $i$ and $p_i$ = mean router probability for expert $i$,
$$\mathcal{L}{aux} = E \cdot \sum{i=1}^{E} f_i , p_i$$
This is minimized when both distributions are uniform ($f_i = p_i = 1/E$ gives $\mathcal{L}_{aux}=1$). $f_i$ is non-differentiable (a count); $p_i$ carries the gradient — the product trick lets gradient flow push probability away from overloaded experts.
Production significance: capacity factors (token budgets per expert with overflow dropping), all-to-all communication in expert parallelism, and per-expert quantization. On edge: MoE means most weights are cold per token — pair with layer/expert streaming (Phase 10).
Chapter 8: State Space Models and Mamba
The motivation: attention pays $O(n^2)$ compute and $O(n)$ KV memory. An RNN pays $O(n)$ compute and $O(1)$ memory but can't be parallelized over time during training and forgets. SSMs are the modern reconciliation.
Continuous origin: a linear time-invariant system $h'(t) = Ah(t) + Bx(t),; y(t) = Ch(t) + Dx(t)$. To run on discrete tokens, discretize with step $\Delta$ using Zero-Order Hold (input held constant within a step):
$$\bar{A} = e^{\Delta A}, \qquad \bar{B} = (\bar{A} - I)A^{-1}B$$
giving the recurrence $h_t = \bar{A}h_{t-1} + \bar{B}x_t$, $y_t = Ch_t + Dx_t$. Intuition: $\bar{A}$ entries (< 1) are memory decay coefficients; $\bar{B}$ is input gain. Large $\Delta$ = step fast, forget fast; small $\Delta$ = retain.
S4's contribution: with fixed $A$ (HiPPO-initialized, diagonal), the whole sequence output is a convolution with a precomputable kernel — train in parallel like a CNN, run as an RNN at inference.
Mamba's contribution — selectivity: make $B, C, \Delta$ functions of the input $x_t$. Now the state update is content-dependent: the model can choose, per token, to store or ignore. This breaks the convolution trick (kernel is input-dependent), so Mamba ships a hardware-aware parallel associative scan computed in SRAM with recomputation in backward — an architecture and a kernel co-designed.
Why NPU/edge people care: O(1) inference state (a $d_{state} \times d$ matrix, not a growing KV cache) is the difference between bounded and unbounded memory for streaming workloads. The risk: recurrent state quantization error compounds over time — evaluate long-sequence accuracy, not just per-op SQNR.
Chapter 9: Vision Transformers and DINO
ViT from zero: chop the image into $p \times p$ patches (16×16 typical), flatten each to a vector, project linearly — now you have a "sentence" of patch tokens; run a standard transformer. The projection is implemented as Conv2d with kernel = stride = $p$ (identical math, one fused op — Lab 04 tests that non-overlap property). A learnable CLS token is prepended as the aggregation point; learned position embeddings restore spatial order.
Inductive bias tradeoff: CNNs hard-code locality and translation equivariance; ViTs learn relations from data — worse on small datasets, better at scale, and their global receptive field from layer 1 is why their attention maps are interesting.
DINO — labels-free training: two networks, student and teacher, same architecture. Teacher weights are an EMA of the student (never gradient-trained). Both see different augmented crops of the same image; the student is trained to predict the teacher's output distribution (cross-entropy). Collapse is prevented by two opposing mechanisms: centering (subtract a running mean from teacher logits — kills the one-hot collapse) and sharpening (teacher temperature τ_t ≈ 0.04 < student τ_s ≈ 0.1 — kills the uniform collapse). Multi-crop makes the task "predict global view from local crop", which forces object-level features — and produces the famous segmentation-quality attention maps with zero labels. Lab 04 implements every piece and tests each property in isolation.
Chapter 10: Multimodal Bridges
The design question: you have a pretrained vision encoder (CLIP) and a pretrained LLM. How do images get into the LLM?
LLaVA's answer (Lab 05): project CLIP's patch tokens through a small MLP into the
LLM's embedding space and prepend them as if they were text tokens. Train only the
projector first (the bridge must learn to "speak embedding-ese" before touching either
tower); optionally unfreeze the LLM later for instruction tuning. The loss is computed on
answer text positions only — visual positions and the prompt are masked with
ignore_index — and there's a one-position causal shift at the modality boundary that is
the canonical off-by-one bug (Lab 05 has a test that catches precisely it).
Alternatives: BLIP-2's Q-Former compresses patches into a fixed small set of query tokens (cheaper sequences); Flamingo interleaves gated cross-attention layers (LM untouched, text throughput preserved, but new layers to train). The engineering axis: prepended tokens inflate the KV cache by $N_{patches}$ at every layer — 576 CLIP tokens often dominate a short VQA prompt — which is why token compression matters on edge.
Lab Walkthrough Guidance
Order: Lab 01 → 02 → 03 → 04 → 05 (attention foundations first; MoE/SSM build on it; vision and multimodal reuse your attention implementation mentally).
- Lab 01 (variants): implement MHA first and get the causal mask + KV cache correct before adding GQA/MQA (the GQA test compares against naive expansion — write that naive expansion yourself to understand it). RoPE before ALiBi.
- Lab 02 (MoE): router → expert → block → utilization. Verify the aux-loss formula against Chapter 7's math by hand on a 2-expert toy.
- Lab 03 (SSM):
discretize_zohis pure math — check it against scalar examples ($a=-1, \Delta=0.1 \Rightarrow \bar{a} \approx 0.905$). Then the sequential scan, then selectivity. - Lab 04 (DINO): follow the README's order; the no-grad-to-teacher and centering tests are the conceptual heart.
- Lab 05 (bridge): draw the
[v_1..v_N | t_1..t_T]position layout on paper before writingloss().
Success Criteria
You are ready for Phase 03 when you can, from memory:
- Compute the KV cache size of any (layers, kv_heads, head_dim, context, dtype) config and state the GQA reduction factor.
- Derive why $\sqrt{d_k}$ scaling exists from the variance of random dot products.
- Explain RoPE's relative-position property (rotations compose; dot product depends on $m-n$) without notes.
- State both MoE collapse modes and how $\sum f_i p_i$ fixes them.
- Write the ZOH discretization from the continuous SSM and explain what selectivity changes computationally (convolution trick breaks → scan).
- Explain DINO's two anti-collapse mechanisms and what happens if either is removed.
- Name the multimodal causal-shift bug and the KV-cache cost of prepended visual tokens.
Interview Q&A
Q: 32K context, LLaMA-3-8B (32 layers, 8 KV heads, head dim 128, FP16). KV cache size? $32 \times 2 \times 8 \times 128 \times 2,\text{B} = 131,\text{KB/token}$; × 32768 tokens ≈ 4.3 GB per sequence. With MHA (32 KV heads) it would be 17 GB — that 4× is GQA's entire reason for existing.
Q: Why does GQA quantize more gracefully than MHA? Fewer distinct KV projection matrices → fewer weight distributions to calibrate, and the shared KV heads see more diverse activation statistics (averaging over query groups), so per-channel ranges are better conditioned. Also the KV cache itself, if quantized, has fewer head-specific distributions.
Q: Your Mamba model passes per-op quantization checks but fails on long sequences. Why? Recurrent state error compounds: each step multiplies state by $\bar{A}$ and adds quantized input contributions; unlike attention (which reads exact cached K/V), small per-step errors accumulate over thousands of steps. Evaluate sequence-level metrics vs length; consider keeping the state and $\Delta$-path in higher precision.
Q: When would you pick ALiBi over RoPE? When the dominant requirement is train-short/infer-long generalization with no inference- time scaling tricks, and the tasks are recency-dominated (streaming ASR post-processing, log modeling). RoPE wins when precise relative structure matters or ecosystem tooling (RoPE-scaling for context extension) is needed.
Q: How would you adapt NPU tiling for an MoE model? Router runs dense and tiny (keep high precision); expert FFNs are the bulk — tile them as independent matmuls with token gathering by expert (sort tokens by expert id to batch); since only K of E experts are hot per token, weights can be streamed/paged per expert, and per-expert quantization scales are natural tile boundaries.
References
- Vaswani et al., Attention Is All You Need (2017) — arXiv:1706.03762
- Ainslie et al., GQA: Training Generalized Multi-Query Transformer (2023) — arXiv:2305.13245
- Su et al., RoFormer: Enhanced Transformer with Rotary Position Embedding (2021) — arXiv:2104.09864
- Press et al., Train Short, Test Long: ALiBi (2021) — arXiv:2108.12409
- Shazeer, GLU Variants Improve Transformer (2020) — arXiv:2002.05202
- Fedus et al., Switch Transformers (2021) — arXiv:2101.03961
- Jiang et al., Mixtral of Experts (2024) — arXiv:2401.04088
- Gu & Dao, Mamba: Linear-Time Sequence Modeling with Selective State Spaces (2023) — arXiv:2312.00752
- Gu et al., Efficiently Modeling Long Sequences with S4 (2021) — arXiv:2111.00396
- Dosovitskiy et al., An Image is Worth 16x16 Words (ViT) (2020) — arXiv:2010.11929
- Caron et al., Emerging Properties in Self-Supervised ViTs (DINO) (2021) — arXiv:2104.14294
- Liu et al., Visual Instruction Tuning (LLaVA) (2023) — arXiv:2304.08485
- The Illustrated Transformer — Jay Alammar
- DEEP-DIVE-MOE-SSM.md — this phase's companion deep dive
Hitchhiker's Guide — Model Architecture Mastery
"Architecture is not what you add; it is what you understand well enough to subtract."
1. The Attention Equation and Its Variants — Precise Math
1.1 Standard Multi-Head Attention
$$\text{MHA}(X) = \text{Concat}(\text{head}_1, \ldots, \text{head}_h) W^O$$
$$\text{head}_i = \text{Attention}(XW^Q_i, XW^K_i, XW^V_i)$$
$$\text{Attention}(Q,K,V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}} + M\right)V$$
where $M$ is the causal mask ($-\infty$ for future positions).
Memory during inference: For sequence length $n$, each layer stores KV cache of shape $(2, \text{batch}, n, h, d_k)$ = $2 \cdot b \cdot n \cdot h \cdot d_k$ floats. For LLaMA-7B: $b=1, n=4096, h=32, d_k=128$ → 32 layers × $2 × 4096 × 32 × 128 × 2$ bytes = 8 GB in FP16.
1.2 Grouped Query Attention
Reduce KV heads from $h$ to $g$ heads:
$$\text{GQA}(X) = \text{Concat}(\text{head}_1, \ldots, \text{head}_h) W^O$$
$$\text{head}i = \text{Attention}(XW^Q_i, XW^K{\lfloor i/r \rfloor}, XW^V_{\lfloor i/r \rfloor})$$
where $r = h/g$ is the repetition factor. When $g=h$: standard MHA. When $g=1$: MQA.
KV cache reduction: $g/h$ fraction of MHA's KV cache. LLaMA-3 uses $g=8, h=32$ → 4× reduction → 2 GB for same scenario.
Implementation trick: no need to actually repeat the KV tensors — use torch.repeat_interleave(k, h//g, dim=1) only during attention computation, not in cache.
1.3 Rotary Position Embeddings (RoPE)
RoPE encodes position $m$ by rotating query/key vectors in 2D subspaces:
$$f_q(x_m, m) = \begin{pmatrix} \cos(m\theta_1) & -\sin(m\theta_1) \ \sin(m\theta_1) & \cos(m\theta_1) \end{pmatrix} \ldots \begin{pmatrix} x_1 \ x_2 \end{pmatrix}$$
where $\theta_i = 10000^{-2(i-1)/d}$ (same base as sinusoidal embeddings).
Key property: $\langle f_q(x_m), f_k(x_n) \rangle$ depends only on $x_m$, $x_n$, and $(m-n)$ — position enters only as relative offset. This is why RoPE can generalize to unseen sequence lengths.
Implementation (the complex number trick):
def apply_rotary_emb(xq, xk, freqs_cis):
# xq: (batch, seq, n_heads, head_dim)
# freqs_cis: (seq, head_dim//2) complex
xq_ = torch.view_as_complex(xq.float().reshape(*xq.shape[:-1], -1, 2))
xk_ = torch.view_as_complex(xk.float().reshape(*xk.shape[:-1], -1, 2))
xq_out = torch.view_as_real(xq_ * freqs_cis).flatten(3)
xk_out = torch.view_as_real(xk_ * freqs_cis).flatten(3)
return xq_out.type_as(xq), xk_out.type_as(xk)
1.4 ALiBi (Attention with Linear Biases)
Instead of position embeddings, add bias $-m \cdot |i - j|$ to attention logits:
$$A_{ij} = \frac{Q_i K_j^T}{\sqrt{d}} - m \cdot |i - j|$$
where $m$ is a per-head slope: $m_h = 2^{-8h/H}$ for head $h$ of $H$ total heads.
Key advantage: completely eliminates position embedding parameters. Generalizes to longer sequences at inference time (slopes penalize large distances). Disadvantage: slightly lower peak performance than RoPE on standard benchmarks.
1.5 SwiGLU vs Standard FFN
Standard FFN: $\text{FFN}(x) = \max(0, xW_1 + b_1)W_2 + b_2$
SwiGLU: $\text{FFN}(x) = (\text{Swish}(xW_1) \odot xW_2)W_3$
where $\text{Swish}(x) = x \cdot \sigma(x)$.
Parameter count: SwiGLU adds $W_2$ (same size as $W_1$) but reduces $d_{ff}$ from $4d$ to $\frac{2}{3} \times 4d \approx 2.67d$ to maintain FLOPs parity. LLaMA uses $d_{ff} = \frac{8}{3} d_{model}$ rounded to multiple of 256.
2. Vision Transformer Internals
2.1 Patch Embedding
An image of size $(H, W, C)$ is divided into $N = \frac{H}{P} \times \frac{W}{P}$ patches of size $(P, P, C)$.
Each patch is linearly projected to $d_{model}$: nn.Conv2d(C, d_model, kernel_size=P, stride=P) — this is equivalent to dividing into patches and projecting each independently, but implemented efficiently as a single strided convolution.
Sequence length: $N + 1$ (N patches + 1 CLS token). For ViT-L with $P=16$, $H=W=224$: $N = 196$.
2.2 Why ViT Attention Heads Learn to Segment
Each patch is a $(P \times P)$ region. The class token attends to all patches to aggregate a global representation. In DINO training, the self-supervised objective forces the model to be invariant to local augmentations while sensitive to semantic content.
Mechanistically: early layers learn local edge/texture features (similar to CNN filters), while deep layers learn to cluster semantically related patches (foreground vs background). The CLS token's attention weights in the last layer correspond to object foreground because:
- The DINO loss forces the CLS token to represent the "semantic essence" of the full image
- Background patches carry less semantic information and receive lower attention weight
- Multiple views (global + local crops in DINO) force the model to find consistent semantic regions
This is not hand-designed — it emerges purely from the training objective.
3. Mixture of Experts Architecture
3.1 Routing Mechanism
Each token independently selects the top-K experts out of E total:
def route(x, W_gate):
# x: (batch, seq, d_model)
logits = x @ W_gate.T # (batch, seq, num_experts)
gates, indices = logits.topk(k=2, dim=-1) # select top-2 experts
gates = F.softmax(gates, dim=-1) # normalize selected expert weights
return gates, indices
Auxiliary load balancing loss: without it, all tokens collapse to 1-2 experts ("router collapse"). The auxiliary loss penalizes unequal expert utilization:
$$L_{\text{aux}} = \alpha \cdot E \cdot \sum_{i=1}^{E} f_i \cdot P_i$$
where $f_i$ = fraction of tokens assigned to expert $i$, $P_i$ = average routing probability to expert $i$.
3.2 MoE Impact on NPU Deployment
MoE models (Mixtral 8x7B, GPT-4-style) are problematic for NPUs because:
- Sparse activation: different tokens activate different experts → no batching across tokens for expert computation
- Expert parameter loading: each expert's weights must be available — 8 experts × 7B params = 56B total (even though only 14B active per token)
- Load imbalance: if multiple tokens route to the same expert simultaneously, that expert becomes a bottleneck
NPU mitigation strategies:
- Expert pruning: drop low-frequency experts for a specific deployment domain
- Expert merging: average 8 experts into 4 (reduces params at slight accuracy cost)
- MoE → dense distillation: distill the sparse model into a single dense FFN
4. Mamba / SSM Architecture
4.1 The Core SSM Equation
Mamba models are discrete-time state space models:
$$h_t = \bar{A} h_{t-1} + \bar{B} x_t$$ $$y_t = C h_t$$
where $h_t \in \mathbb{R}^N$ is the hidden state, $x_t, y_t \in \mathbb{R}$ are input/output, and $\bar{A}, \bar{B}$ are discretized from continuous-time parameters.
Selective SSM (what makes Mamba work): $B$, $C$, and $\Delta$ (discretization timescale) are input-dependent:
$$\bar{B}t = f_B(x_t), \quad C_t = f_C(x_t), \quad \Delta_t = \text{softplus}(f\Delta(x_t))$$
This gives the model the ability to selectively forget or remember based on content — equivalent to attention's dynamic weighting but in $O(n)$ time.
4.2 Mamba vs Attention for NPU Deployment
| Property | Transformer (Attention) | Mamba (SSM) |
|---|---|---|
| Inference time (sequence length $n$) | $O(n)$ per token (with KV cache) | $O(1)$ per token (recurrent) |
| Memory (KV cache) | $O(n)$ per layer per batch | $O(1)$ per layer per batch (state only) |
| Parallelism (training) | Full $O(n^2)$ parallelism | Parallel scan ($O(n \log n)$ or $O(n)$ with associativity) |
| NPU friendliness | High (large matmuls) | Medium (recurrent ops, smaller matmuls) |
| Long-sequence accuracy | Degrades without positional bias tuning | Strong by design |
5. Key Interview Numbers
| Architecture | Active Params per Token | KV Cache per Layer (FP16, n=4096) | Notes |
|---|---|---|---|
| LLaMA-3-8B | 8B | 4 MB (GQA, g=8) | h=32, g=8, d=128 |
| LLaMA-3-70B | 70B | 16 MB (GQA, g=8) | h=64, g=8, d=128 |
| Mixtral 8x7B | ~13B (top-2 routing) | 8 MB | 8 experts, 2 active |
| ViT-L/14 | 307M | N/A (no KV cache at inference) | 196 patches |
| Mamba-3B | 3B | 1 MB flat state | No KV cache growth |
6. Resources
- Attention is All You Need (Vaswani et al., 2017) — the foundation
- GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints (Ainslie et al., 2023)
- RoFormer: Enhanced Transformer with Rotary Position Embedding (Su et al., 2021)
- Train Short, Test Long: Attention with Linear Biases (ALiBi) (Press et al., 2021)
- GLU Variants Improve Transformer (Noam Shazeer, 2020) — SwiGLU origin
- An Image is Worth 16×16 Words: Transformers for Image Recognition at Scale (Dosovitskiy et al., 2020)
- Emerging Properties in Self-Supervised Vision Transformers (DINO) (Caron et al., 2021)
- Mamba: Linear-Time Sequence Modeling with Selective State Spaces (Gu & Dao, 2023)
- Mixtral of Experts (Mistral AI, 2024)
👨🏻 Brother Talk — Phase 02, Off the Record
Architecture mastery — where the JD says "expertise in model architecture, backed by literature rationale and intuition." That word intuition is the whole game.
Brother, read the JD line again: "Expertise in model architecture, backed by literature rationale and intuition." Most candidates hear "know the architectures" and go memorize that a transformer has attention and an FFN. That is not what this asks. It asks for rationale — why this design and not another — and intuition — what happens to accuracy and latency when you change it. At Senior Staff level you're not implementing someone else's paper; you're the person who looks at a new architecture and says "this MoE will be a nightmare on our NPU because of the gather/scatter, but here's the variant that won't." That judgment is what this phase builds.
Here's the reframe: architectures are not a zoo of unrelated animals; they're a sequence of answers to a small set of recurring problems. Attention is quadratic → so we get sparse attention, linear attention, and eventually SSMs (Mamba) that ditch attention for a recurrence with near-linear cost. Dense FFNs are expensive → so we get Mixture-of-Experts that activates only k of N experts. Vision needs inductive bias → ViT throws it away and recovers it with scale and self-distillation (DINO). Once you see architectures as moves in response to a constraint, you can reason about ones you've never seen — which is exactly the interview test.
Now the part that matters for this job specifically, and that pure-ML people miss: the best architecture on a GPU benchmark is often the worst architecture on an NPU. This is the Qualcomm lens, and it's your edge. MoE looks great on FLOPs-per-token, but the expert routing is a dynamic gather/scatter that NPUs (which love static, dense, predictable dataflow) hate — you get memory-bound stalls and CPU fallbacks. SSMs have a beautiful linear recurrence, but the scan is sequential and maps awkwardly to hardware built for parallel matmuls. A candidate who can say "yes the paper shows X, but on a mobile NPU the real bottleneck is the irregular memory access, so here's what I'd actually ship" — that person gets hired, because that's the literal job: adapt GenAI techniques for Qualcomm SOCs.
The MoE lab will teach you the thing that trips everyone: load balancing. Naive top-k routing collapses — a few experts get all the tokens, the rest die, and your "8 experts" is really 2. The auxiliary load-balancing loss isn't a footnote; it's what makes MoE work at all. And on hardware, an unbalanced MoE is a latency disaster because the busiest expert gates the whole layer. Build it, watch the collapse happen, watch the aux loss fix it. That's intuition you can't get from reading.
The SSM/Mamba lab is the one that'll make you feel smart, because the selective-scan recurrence is genuinely elegant — and it's the future-facing skill (the JD wants "latest GenAI techniques adapted for NPUs"). But hold the Qualcomm skepticism: the parallel scan that makes Mamba fast on a GPU is exactly the part that's hard to map to an NPU's dataflow. Knowing both the elegance and the hardware friction is the Senior Staff take.
On ViT + DINO (the lab I had to fix the test for — the loss genuinely decreases, the old test just used an unfair baseline): the lesson beyond the architecture is self-distillation without labels. A teacher that's an EMA of the student, centering to prevent collapse, sharpening to make confident targets — it's a deceptively deep training dynamic. The collapse modes (everything maps to one vector; everything maps to uniform) are real failure modes you'll see in production self-supervised training, and centering vs sharpening are the two knobs that fight them. Understanding why it doesn't collapse is more valuable than the architecture itself.
Multimodal (CLIP→LLM bridge): this is where the field is going and where edge GenAI gets interesting. The bridge — projecting vision features into the LLM's token space — is a small module with outsized importance, and on-device it's where a lot of the accuracy/latency tradeoff lives. Know how the modalities get glued, because "run a multimodal model on a phone" is a sentence you'll hear at this job.
Career truth, brother: architecture knowledge ages, but architecture intuition compounds. The specific models will be obsolete in two years; the ability to look at a new one and reason about its accuracy, its cost, and its hardware-friendliness is permanent. In interviews, the tell of a senior person is that they don't just describe an architecture — they critique it, they know its failure modes, and (for this role) they know how it'll behave on silicon. Read the papers for the rationale, build the labs for the intuition, and always ask "how does this map to the NPU?" — that question is your whole differentiator.
Build the variants. Make the MoE collapse and then fix it. Then come to P03, where we make these architectures small enough to ship — quantization, where accuracy goes to die if you're not careful.
— your brother 👨🏻
Deep Dive — Mixture of Experts & State Space Models (Phase 02)
"MoE and SSMs are not incremental improvements — they are fundamental rethinks of how sequence models should scale."
Section 1: Mixture of Experts — From Dense to Sparse
1.1 The Scaling Problem with Dense Models
Every parameter in a standard transformer is activated for every input token. A 70B dense model runs 70B multiplications per token. This is expensive, and evidence from scaling laws (Hoffmann et al., Chinchilla) shows that most parameters could be replaced by conditional computation — only activate what you need.
Key question: Can we get the capacity of a large model while spending compute equivalent to a smaller one?
Yes: Mixture of Experts.
1.2 The MoE Layer Architecture
Replace the Feed-Forward Network (FFN) in each Transformer block with $N$ independent FFN "experts" and a router that chooses which experts process each token.
Standard Transformer Block:
Token x → MHA → LayerNorm → FFN → LayerNorm → output
MoE Transformer Block:
Token x → MHA → LayerNorm → [Router] → [Expert 0, Expert 1, ..., Expert N-1]
↑ only K experts activated per token
The architecture for a single transformer layer:
class TransformerBlock(nn.Module):
def __init__(self, d_model, n_heads, n_experts=8, top_k=2):
super().__init__()
self.attn = MultiHeadAttention(d_model, n_heads)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.moe = MoEBlock(d_model, d_ff=4*d_model, n_experts=n_experts, top_k=top_k)
def forward(self, x):
x = x + self.attn(self.norm1(x))
moe_out, aux_loss = self.moe(self.norm2(x))
x = x + moe_out
return x, aux_loss
Parameter count:
- Dense FFN: $2 \times d_{model} \times d_{ff}$ (e.g., 2×4096×16384 = 134M params)
- MoE with 8 experts: $8 \times 2 \times d_{model} \times d_{ff/8}$ = same total parameters but only top-2 experts activate → 2/8 = 25% of compute
This is how Mixtral 8×7B has 47B parameters but computes like a ~13B model.
1.3 The Router: Soft Routing
The router is a learned linear projection from $d_{model}$ to $N$ expert logits, normalized with softmax:
Input x ∈ ℝ^{d_model}
Logits: g = W_router @ x ∈ ℝ^N
Probs: p = softmax(g) ∈ ℝ^N
Top-K: (indices, weights) = topk(p, K)
Renorm: weights = weights / sum(weights)
In code:
class TopKRouter(nn.Module):
def __init__(self, d_model, n_experts, top_k=2):
super().__init__()
self.n_experts = n_experts
self.top_k = top_k
# NOTE: shape is (n_experts, d_model) so output is logit per expert
self.router_weight = nn.Linear(d_model, n_experts, bias=False)
def forward(self, x):
# x: [batch*seq, d_model]
logits = self.router_weight(x) # [N, n_experts]
probs = F.softmax(logits, dim=-1) # [N, n_experts]
topk_w, topk_idx = probs.topk(self.top_k, dim=-1) # [N, K] each
# Renormalize weights to sum to 1
topk_w = topk_w / topk_w.sum(dim=-1, keepdim=True)
return topk_idx, topk_w
Walkthrough with numbers: Suppose N=6 tokens, 4 experts, top-K=2.
x = [6, d_model]
logits = router(x) → [6, 4]
Example for token 0: logits = [2.1, 0.3, -0.5, 1.8]
probs = [0.50, 0.09, 0.04, 0.37]
topk = indices=[0,3], weights=[0.50, 0.37]
renorm = weights=[0.575, 0.425]
→ Token 0 goes to experts 0 and 3 with weights 0.575, 0.425
The MoE block output for each token: $$y_i = \sum_{k \in \text{TopK}(i)} w_{ik} \cdot E_k(x_i)$$
where $E_k$ is the $k$-th expert FFN.
1.4 Load Balancing: The Critical Problem
Expert collapse: without any penalty, the router quickly learns to always route to the same 1-2 experts (because they get more training signal and become better). Other experts starve and become useless. Your MoE degenerates to a dense FFN.
Measurement: define load imbalance = (tokens to most popular expert) / (ideal even split).
For 8 experts with 100 tokens: ideal = 100/8 = 12.5 tokens each. If expert 0 gets 80 tokens: imbalance = 80/12.5 = 6.4× — near-total collapse.
Solution: Switch Transformer auxiliary loss (Fedus et al., 2021):
For expert $i$:
- $f_i$ = fraction of tokens assigned to expert $i$ (counting top-K assignments)
- $P_i$ = mean router probability for expert $i$ across all tokens
$$\mathcal{L}{aux} = N{experts} \cdot \sum_{i=1}^{N_{experts}} f_i \cdot P_i$$
The minimum of $\mathcal{L}_{aux}$ is $1.0$ (achieved when routing is perfectly uniform). Multiplying $f_i \cdot P_i$ creates a smooth gradient: even though the top-K selection is non-differentiable, $P_i$ is differentiable and carries gradient back to the router weights.
In code:
def compute_aux_loss(probs, topk_indices, n_experts):
"""
probs: [N, n_experts] — soft router probs (differentiable)
topk_indices:[N, top_k] — hard selections (non-differentiable)
"""
N = probs.shape[0]
# f_i: fraction of tokens dispatched to expert i (from hard selections)
# Count each top-k assignment equally
one_hot = torch.zeros(N, n_experts, device=probs.device)
for k in range(topk_indices.shape[1]):
one_hot.scatter_add_(1, topk_indices[:, k:k+1],
torch.full((N,1), 1.0/topk_indices.shape[1], device=probs.device))
f = one_hot.mean(dim=0) # [n_experts] — fraction of tokens to each expert
# P_i: mean router probability (differentiable!)
P = probs.mean(dim=0) # [n_experts]
aux_loss = n_experts * (f * P).sum()
return aux_loss
Walkthrough: 4 experts, 4 tokens, top-1:
Routing: [E0, E1, E0, E3]
f = [0.5, 0.25, 0.0, 0.25] (E0 gets 2/4=0.5)
P = [0.35, 0.20, 0.15, 0.30] (average softmax probs)
aux = 4 * (0.5*0.35 + 0.25*0.20 + 0.0*0.15 + 0.25*0.30)
= 4 * (0.175 + 0.05 + 0 + 0.075)
= 4 * 0.30 = 1.20 (>1.0 → imbalanced, gradient pushes toward 1.0)
In training: add α * L_aux to the main loss. Typical α = 0.01.
1.5 Expert Execution: Dispatching Tokens
The naive implementation loops over every token × every K expert — O(N×K) sequential calls. Production implementations batch tokens per expert:
# Production MoE dispatch (simplified)
def moe_forward_batched(x, router, experts):
B, T, d = x.shape
x_flat = x.reshape(B*T, d) # [N, d]
N = x_flat.shape[0]
K = router.top_k
indices, weights = router(x_flat) # [N, K], [N, K]
output = torch.zeros_like(x_flat)
# Group tokens by expert for batched execution
for e in range(router.n_experts):
# Find all (token, k) pairs that route to expert e
token_mask = (indices == e) # [N, K] bool
if not token_mask.any():
continue
# Gather tokens going to this expert
tok_ids, k_ids = token_mask.nonzero(as_tuple=True)
expert_input = x_flat[tok_ids] # [M, d] where M = tokens to this expert
expert_out = experts[e](expert_input) # [M, d]
# Scatter back with weights
output.scatter_add_(0,
tok_ids.unsqueeze(1).expand_as(expert_out),
expert_out * weights[tok_ids, k_ids].unsqueeze(1))
return output.reshape(B, T, d)
1.6 MoE in Real Models
| Model | Experts | Active per token | d_model | Notes |
|---|---|---|---|---|
| Switch Transformer | 128–2048 | 1 (top-1) | 1024–4096 | Google, top-1 for simplicity |
| GLaM | 64 | 2 | 8192 | 1.2T params, 97B active |
| Mixtral 8×7B | 8 | 2 | 4096 | Most popular open-source MoE |
| GPT-4 (rumored) | 16 | 2 | ~12288 | Dense+MoE hybrid |
| Gemini 1.5 | ? | 2 | ? | MoE architecture confirmed |
Interview question: "Why does Mixtral use top-2, not top-1?"
Answer: Top-1 is simpler but creates extreme routing variance — small perturbations change which expert is used entirely. Top-2 provides smoother gradients and more stable training while still maintaining sparsity.
Section 2: State Space Models — The Linear Alternative to Attention
2.1 Why Attention Has a Quadratic Problem
For a sequence of length $T$:
- Attention matrix: $T \times T$ — quadratic memory, quadratic compute
- At T=32768 and FP16: 32768² × 2 bytes = 2 GB just for attention weights
- This makes long-context training extremely expensive
Can we do sequence modeling in O(T) memory and O(T) compute? State Space Models say yes.
2.2 Classical SSMs — The Continuous Foundation
State Space Models from control theory describe a dynamical system:
$$\dot{h}(t) = Ah(t) + Bx(t)$$ $$y(t) = Ch(t) + Dx(t)$$
Where:
- $x(t) \in \mathbb{R}$ — scalar input signal at time $t$
- $h(t) \in \mathbb{R}^N$ — hidden state vector (the "memory" of the system)
- $y(t) \in \mathbb{R}$ — output
- $A \in \mathbb{R}^{N \times N}$ — state transition matrix
- $B \in \mathbb{R}^{N \times 1}$ — input projection
- $C \in \mathbb{R}^{1 \times N}$ — output projection
- $D$ — skip connection scalar (often D=1 or D=0)
Concrete example: $N=2$ state, like a damped oscillator:
A = [[-0.5, 1.0], B = [[0.0], C = [[1.0, 0.0]]
[-1.0, -0.5]] [1.0]]
This system: state h = [position, velocity]
x = external force
y = position
The negative eigenvalues of A (-0.5 ± i) ensure the system is stable (oscillations decay).
2.3 Discretization via Zero-Order Hold (ZOH)
Computers process discrete sequences, not continuous signals. We need to convert $A, B$ to their discrete counterparts $\bar{A}, \bar{B}$ for time step $\Delta$:
$$\bar{A} = e^{\Delta A}$$ $$\bar{B} = (e^{\Delta A} - I) A^{-1} B$$
For diagonal A (which is what S4/Mamba use for efficiency), this simplifies to element-wise:
$$\bar{A}_i = e^{\Delta \cdot A_i}$$ $$\bar{B}_i = \frac{e^{\Delta \cdot A_i} - 1}{A_i} B_i$$
Why diagonal A? Computing $e^{A}$ for a dense $N \times N$ matrix is $O(N^3)$. For diagonal A, it's $O(N)$.
Stability requirement: $A_i < 0$ ensures $|\bar{A}_i| = |e^{\Delta \cdot A_i}| < 1$ — the state decays over time (like a leaky memory). S4 initializes A using the HIPPO matrix theory (approximating Legendre polynomials for long-range memory), then parameterizes $A_i = -\text{softplus}(A_\log_i)$ to maintain negativity.
Code walkthrough:
def discretize_zoh(A, B, delta):
"""
A: [d_state] diagonal of continuous-time state matrix (negative)
B: [d_state] input projection
delta: scalar time step size (learned or fixed)
Returns:
A_bar: [d_state] discrete state transition
B_bar: [d_state] discrete input projection
"""
# A_bar = exp(delta * A) — element-wise since A is diagonal
A_bar = torch.exp(delta * A)
# B_bar = (exp(delta*A) - 1) / A * B
# Note: A < 0, so (A_bar - 1) < 0, and A < 0, giving B_bar > 0
B_bar = (A_bar - 1.0) / A * B
return A_bar, B_bar
# Example:
A = torch.tensor([-1.0, -2.0, -0.5]) # 3-state SSM, all negative
B = torch.tensor([1.0, 1.0, 1.0])
delta = 0.1 # time step
A_bar, B_bar = discretize_zoh(A, B, delta)
# A_bar ≈ [0.905, 0.819, 0.951] — memory coefficients, all < 1
# B_bar ≈ [0.095, 0.181, 0.049] — input gain
2.4 The Discrete Recurrence
Once discretized, the SSM runs as a linear recurrence:
$$h_t = \bar{A} \cdot h_{t-1} + \bar{B} \cdot x_t$$ $$y_t = C \cdot h_t + D \cdot x_t$$
For each time step $t$ we need $h_{t-1}$, so this must run sequentially in RNN mode.
But: for training, we can also run it as a convolution! Unrolling the recurrence:
$$h_1 = \bar{B} x_1$$ $$h_2 = \bar{A}\bar{B}x_1 + \bar{B}x_2$$ $$h_T = \sum_{k=1}^{T} \bar{A}^{T-k} \bar{B} x_k$$
The output $y_T = C h_T = \sum_k C \bar{A}^{T-k} \bar{B} x_k$.
Define the SSM kernel: $\bar{K} = (C\bar{B},\ C\bar{A}\bar{B},\ C\bar{A}^2\bar{B},\ \ldots,\ C\bar{A}^{T-1}\bar{B})$
Then $y = x * \bar{K}$ — a standard 1D convolution! This can be computed in $O(T \log T)$ with FFT.
Training (parallel): y = conv(x, K) → O(T log T) ← convolve entire sequence at once
Inference (causal): y_t = C*h_t → O(1) per step ← just maintain state h
This dual-mode property is the key advantage: fast training via convolution, fast inference via recurrence.
2.5 The S4 Model
S4 (Structured State Space Sequence, Gu et al., 2021) makes SSMs practical:
- Diagonal-plus-low-rank (DPLR) parameterization of A — can be efficiently computed
- HIPPO initialization: A initialized to approximate memorizing the input using Legendre polynomial projections
- Convolutional mode for training on GPUs
The S4 layer processes each feature channel independently with its own SSM:
Input: x ∈ [B, T, d_model]
↓ (process each feature channel independently)
For each feature f ∈ [0, d_model):
SSM_f: x[:, :, f] → y[:, :, f]
(each has its own A_f, B_f, C_f ∈ ℝ^{d_state})
Output: y ∈ [B, T, d_model]
2.6 Mamba: Selective State Spaces
The problem with S4: A, B, C are fixed for all inputs. The model can't selectively remember or forget based on content.
Mamba (Gu & Dao, 2023) makes B, C, and Δ input-dependent — "selective":
$$B_t = \text{Linear}_B(x_t)$$ $$C_t = \text{Linear}C(x_t)$$ $$\Delta_t = \text{softplus}(\text{Linear}\Delta(x_t))$$
Now A is still learned (but fixed at inference), while the way inputs are projected into state and read out changes per-token. This gives the model ability to:
- Focus: high $\Delta_t$ → strong input absorption (large $\bar{B}_t$)
- Forget: small $\Delta_t$ → nearly identity state update ($\bar{A}_t \approx I$)
- Select: B, C can attend to specific content in the input
Comparison to attention:
| Property | Attention | Mamba |
|---|---|---|
| Memory | O(T²) | O(T) |
| Compute | O(T²) | O(T) |
| Content-selectivity | ✅ (QK dot product) | ✅ (selective B, C, Δ) |
| Parallelism in training | ✅ | ✅ (parallel scan) |
| Inference per step | O(T) KV cache | O(1) constant state |
2.7 The Selective Scan: How Mamba is Actually Computed
At inference time: simple recurrence (O(1) per step)
At training time: naive sequential loop is O(T), but GPUs need parallelism. Mamba uses parallel prefix scan (a.k.a. "scan" or "cumulative operation"):
Parallel prefix scan for associative operation $\oplus$:
- Input: $[a_1, a_2, a_3, a_4]$
- Output: $[a_1, a_1 \oplus a_2, a_1 \oplus a_2 \oplus a_3, a_1 \oplus a_2 \oplus a_3 \oplus a_4]$
For SSM recurrence: $h_t = A_t h_{t-1} + B_t x_t$
Define pair operation: $(A_2, B_2) \oplus (A_1, B_1) = (A_2 A_1,\ A_2 B_1 + B_2)$
This is associative — can be computed in $O(\log T)$ depth with $O(T)$ work in parallel!
Mamba's actual implementation (in Triton/CUDA) uses hardware-aware recomputation during backward pass to avoid storing O(T) states.
2.8 Full Mamba Block Architecture
Input x ∈ [B, T, d_model]
│
├──── in_proj ──→ x_in ∈ [B, T, d_inner]
│ │
│ depthwise conv1d (causal)
│ │
│ silu activation
│ │
│ SelectiveSSM (Δ, B, C input-dependent)
│ │
└──── in_proj ──→ z ∈ [B, T, d_inner]
│
(SSM_out * silu(z)) ← gating
│
out_proj ──→ output ∈ [B, T, d_model]
The depthwise conv provides local context (a few tokens of short-range dependency) while the SSM provides global context (entire sequence history in the state).
class MambaBlock(nn.Module):
def __init__(self, d_model, d_state=16, d_conv=4, expand=2):
super().__init__()
d_inner = int(expand * d_model)
self.d_inner = d_inner
# Expand input to two streams: x_in and z
self.in_proj = nn.Linear(d_model, 2 * d_inner, bias=False)
# Causal depthwise conv for local context
# padding = d_conv - 1 ensures output length = input length (causal)
self.conv1d = nn.Conv1d(d_inner, d_inner, d_conv,
padding=d_conv-1, groups=d_inner)
# SSM projections
self.x_proj = nn.Linear(d_inner, d_state*2 + d_inner, bias=False) # delta, B, C
self.dt_proj = nn.Linear(d_inner, d_inner) # refine delta
# A: diagonal, initialized log-spaced for multi-scale memory
A_log = torch.log(torch.arange(1, d_state+1).float()).repeat(d_inner, 1)
self.A_log = nn.Parameter(A_log)
self.D = nn.Parameter(torch.ones(d_inner))
self.out_proj = nn.Linear(d_inner, d_model, bias=False)
def forward(self, x):
B, T, _ = x.shape
# Split into two streams
xz = self.in_proj(x) # [B, T, 2*d_inner]
x_in, z = xz.chunk(2, dim=-1) # each [B, T, d_inner]
# Causal depthwise conv (truncate to T to ensure causality)
x_conv = self.conv1d(x_in.transpose(1, 2))[:, :, :T] # [B, d_inner, T]
x_act = F.silu(x_conv).transpose(1, 2) # [B, T, d_inner]
# Selective SSM
y = self.selective_scan(x_act) # [B, T, d_inner]
# Gating: multiply by silu(z) branch
output = self.out_proj(y * F.silu(z))
return output
def selective_scan(self, x):
B, T, d = x.shape
A = -F.softplus(self.A_log) # [d_inner, d_state], negative
# Project x → delta, B_input, C_input
delta_BC = self.x_proj(x) # [B, T, d_state*2 + d_inner]
delta_raw, B_in, C_in = delta_BC.split([d, self.A_log.shape[1], self.A_log.shape[1]], dim=-1)
delta = F.softplus(self.dt_proj(delta_raw)) # [B, T, d_inner]
# For each batch item, run sequential scan
# (actual Mamba uses optimized CUDA parallel scan)
h = torch.zeros(B, d, self.A_log.shape[1], device=x.device)
ys = []
for t in range(T):
dt = delta[:, t, :, None] # [B, d, 1]
A_bar = torch.exp(dt * A[None]) # [B, d, d_state]
B_bar = (A_bar - 1) / A[None] * B_in[:, t, None, :] # [B, d, d_state]
h = A_bar * h + B_bar
y_t = (C_in[:, t, None, :] * h).sum(-1) + self.D * x[:, t, :]
ys.append(y_t)
return torch.stack(ys, dim=1)
2.9 Memory Comparison: O(T²) vs O(T)
Let's make this concrete:
| Sequence length T | Attention (T²) elements | SSM (T × d_state, d_state=16) | Ratio |
|---|---|---|---|
| 64 | 4,096 | 1,024 | 4× |
| 1,024 | 1,048,576 | 16,384 | 64× |
| 8,192 | 67,108,864 | 131,072 | 512× |
| 32,768 | 1,073,741,824 | 524,288 | 2048× |
At 32k tokens, attention needs 2 GB for a single head, while the SSM state is 1 MB. This is why Mamba-like architectures are the leading approach for truly long-context models (1M+ tokens).
2.10 Interview Pitfalls
Q: "What's the fundamental trade-off between MoE and SSMs?"
MoE addresses width scaling — more parameters, same compute. SSMs address temporal scaling — longer sequences, same memory. GPT-4 reportedly uses both: MoE blocks for width efficiency, plus the base transformer architecture. Future models will likely combine MoE (for capacity) with SSM or linear attention (for long context).
Q: "Why can't you just use a very large d_state in SSMs to approximate attention?"
Theoretically yes, but: (1) SSMs have a finite-impulse response limitation — with $N$ state dimensions, you can represent at most $N$ distinct "memories". Attention can attend to any of $T$ previous tokens. (2) The selectivity of attention (content-based retrieval) is more powerful than SSM's "smooth decay + input projection" model. In practice, Mamba-2 and similar hybrid models alternate between SSM layers and full attention layers to get the best of both.
Q: "How does Mixtral achieve competitive quality with a dense model 4× larger?"
The key insight is that language modeling doesn't require every parameter to be active on every token. Different "topics" or "styles" of computation naturally cluster to different experts. A math-reasoning token activates different experts than a poetry token. The model effectively partitions its capacity by input domain.
Q: "Explain load balancing loss without looking at the code."
"The naive router collapses to using one expert because that expert gets more gradient signal and becomes better, attracting more traffic — a positive feedback loop. The aux loss breaks this by penalizing the product $f_i \times P_i$: $f_i$ is the fraction of tokens going to expert $i$ (from the hard top-K selection, non-differentiable) and $P_i$ is the mean softmax probability for expert $i$ (differentiable). Their product peaks when load is uneven, so minimizing $\sum f_i P_i$ pushes the router toward uniform load. The neat trick is that even though the routing decision itself has no gradient, $P_i$ does — so we can still backpropagate."
Section 3: Quick Setup Guide
Installing dependencies
pip install torch>=2.1.0
# For actual Mamba (hardware-optimized):
pip install mamba-ssm # requires CUDA
# Or the pure-PyTorch reference:
pip install mamba-minimal
Running your first MoE forward pass
import torch
from solution import MoEBlock
torch.manual_seed(42)
B, T, d = 2, 16, 128
moe = MoEBlock(d_model=d, d_ff=512, n_experts=4, top_k=2)
x = torch.randn(B, T, d)
out, aux_loss = moe(x)
print(f"Input: {x.shape}") # [2, 16, 128]
print(f"Output: {out.shape}") # [2, 16, 128]
print(f"Aux loss: {aux_loss.item():.4f}") # > 1.0 if imbalanced
# Check expert utilization
from solution import compute_expert_utilization
util = compute_expert_utilization(moe.router, x)
print(f"Expert loads: {[f'{l:.2f}' for l in util['expert_load']]}")
print(f"Imbalance: {util['load_imbalance']:.2f}x")
Running your first SSM forward pass
import torch
from solution import MambaBlock, discretize_zoh, s4_recurrence
# Test S4 recurrence
d = 32
A = -torch.ones(d) # stable (negative diagonal)
B = torch.randn(d)
C = torch.randn(d)
A_bar, B_bar = discretize_zoh(A, B, delta=0.1)
x_seq = torch.randn(64, d) # sequence of 64 timesteps
y_seq = s4_recurrence(x_seq, A_bar, B_bar, C)
print(f"S4 output: {y_seq.shape}") # [64, 32]
# Test MambaBlock
mamba = MambaBlock(d_model=32, d_state=8)
x = torch.randn(1, 64, 32) # [B, T, d_model]
y = mamba(x)
print(f"Mamba output: {y.shape}") # [1, 64, 32]
Lab 01 — Transformer Architectural Variants
Phase: 02 — Model Architecture Mastery | Difficulty: ⭐⭐⭐⭐☆ | Time: 3–4 hours
Read
../HITCHHIKERS-GUIDE.mdbefore starting.
What you build
Implement and compare four key attention variants used in production LLMs:
- Multi-Head Attention (MHA) — original Transformer (Vaswani et al.)
- Multi-Query Attention (MQA) — single KV head, multiple Q heads (PaLM, Falcon)
- Grouped-Query Attention (GQA) — G KV groups (Llama 2/3, Mistral)
- RoPE positional embeddings — rotary position encoding used in Llama/GPT-NeoX
Key concepts
| Concept | What to understand |
|---|---|
| KV cache | MQA/GQA reduce memory for long context inference |
| Head grouping | G groups share KV, reducing VRAM at minimal accuracy cost |
| RoPE | Rotates Q and K before dot product — relative positions for free |
| Flash-attention pattern | Why attention needs tiling for memory efficiency |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Full working implementation + smoke test |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Lab 02 — Mixture of Experts (MoE)
Phase: 02 — Model Architecture Mastery | Difficulty: ⭐⭐⭐⭐⭐ | Time: 3–4 hours
This lab implements the MoE layer used in GPT-4, Mixtral, and Switch Transformer.
What you build
- TopKRouter — linear routing with top-K expert selection and Switch Transformer auxiliary load-balancing loss
- ExpertLayer — FFN expert (w1 → GELU → w2)
- MoEBlock — routes each token to K experts, combines weighted outputs
- compute_expert_utilization — detects load imbalance
Key concepts
| Concept | What to understand |
|---|---|
| Conditional compute | Each token uses only K of N experts (sparse activation) |
| Aux loss | n_experts * Σ(f_i * p_i) — penalizes load imbalance |
| Expert collapse | When all tokens route to same expert — training failure mode |
| Top-1 vs Top-2 | Switch Transformer uses top-1; Mixtral/GPT-4 uses top-2 |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Lab 03 — State Space Models (S4 / Mamba)
Phase: 02 — Model Architecture Mastery | Difficulty: ⭐⭐⭐⭐⭐ | Time: 4–5 hours
Mamba (Gu & Dao, 2023) achieves linear time/memory in sequence length vs quadratic for attention.
What you build
discretize_zoh— Zero-Order Hold discretization: Ā = exp(ΔA), B̄ = (Ā-I)/A·Bs4_recurrence— Sequential SSM scan over a sequenceSelectiveSSM— Mamba's input-dependent B, C, Δ (selection mechanism)MambaBlock— full block: in_proj + depthwise conv + SelectiveSSM + gatingcompare_memory_usage— O(T²) attention vs O(T·d_state) SSM
Key concepts
| Concept | What to understand |
|---|---|
| SSM state equation | h_t = Ā·h_{t-1} + B̄·x_t, y_t = C·h_t + D·x_t |
| ZOH discretization | Converts continuous-time A, B to discrete Ā, B̄ |
| Selective scan | B, C, Δ are functions of x — allows content-based memory |
| Linear recurrence | O(N) sequential scan vs O(N²) attention matrix |
| Hardware-efficient | Mamba uses parallel scan + recomputation for GPU efficiency |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Lab 04 — ViT + DINO Self-Distillation
Phase: 02 — Model Architecture Mastery | Difficulty: ⭐⭐⭐⭐⭐ | Time: 4–6 hours
DINO (Caron et al., 2021) trains a ViT with no labels and gets attention maps that segment objects. This lab builds both the ViT and the self-distillation machinery from scratch, and makes you prove you understand why it doesn't collapse.
What you build
PatchEmbed— the conv-as-patch-projection trick (kernel = stride = patch size)ViT— CLS token, learned position embeddings, pre-norm encoder blocks, plusget_last_attentionfor the famous segmentation-without-labels visualizationDINOHead— MLP → L2-normalized bottleneck → prototype layer (cosine similarity against K learned prototypes)DINOLoss— cross-entropy between sharpened teacher and student distributions with EMA centering — the two mechanisms that jointly prevent collapsemomentum_update— the EMA teacher (a temporal ensemble of past students)dino_train_step— multi-crop: teacher sees global views only, student sees all, loss pairs every teacher view with every different student view
Key concepts
| Concept | What to understand |
|---|---|
| Collapse modes | Uniform output (fixed by sharpening, τ_t < τ_s) and one-hot output (fixed by centering) — each fix alone admits the other collapse |
| Momentum teacher | Never trained by gradients; EMA of student weights is a smoother, better target than the student itself |
| Multi-crop | Local crop → global view correspondence is the actual supervisory signal |
| Stop-gradient | Teacher outputs are detached; gradients flow only through the student branch |
| Emergent segmentation | CLS-token attention rows of the last block localize objects with zero segmentation labels |
Files
| File | Purpose |
|---|---|
lab.py | Skeleton with # TODO markers |
solution.py | Complete reference with commentary; python solution.py runs a synthetic demo |
test_lab.py | 13 tests: patch non-overlap, attention rows, no-grad-to-teacher, centering math, EMA math, loss decrease |
requirements.txt | torch, pytest |
Run
pip install -r requirements.txt
pytest test_lab.py -v # your lab.py
LAB_MODULE=solution pytest test_lab.py -v # sanity-check the reference
Suggested TODO order
PatchEmbed— get the conv-as-projection reshape right firstViT.forward— CLS prepend, pos embed, blocks, norm, return position 0DINOHead— note the L2 normalize before the prototype layerDINOLoss— the detach and the center update are where the learning ismomentum_update+ theDINOwrapper (teacher frozen!)dino_train_step— pair views correctly, EMA after optimizer step
Success criteria
- All 13 tests pass
- You can explain, without notes, why sharpening alone or centering alone still collapses
- You can state what the teacher would output if you forgot the
detach()and trained it by gradient (hint: student and teacher co-collapse to the trivial solution) python solution.pystyle training shows the loss decreasing on repeated identical crops
Full-scale extension (the original spec)
Train ViT-Tiny on CIFAR-10 for 30 epochs (≈1 h single GPU): real augmentation multi-crop, cosine momentum schedule 0.996→1.0, linear-probe accuracy ≥80% without labels, and attention map visualizations showing object segmentation. Add register tokens (DINOv2) and compare.
Interview Q&A
Q: Why does DINO need both centering and sharpening? Centering subtracts a running mean of teacher logits, preventing any prototype from permanently dominating (one-hot collapse) — but pushes toward uniform. Sharpening (low teacher temperature) pushes toward confident outputs — but alone allows one-hot collapse. They balance in opposite directions; remove either and training degenerates.
Q: Why is the teacher an EMA rather than a copy or a separately trained network? EMA gives a temporal ensemble of student checkpoints: smoother and consistently better than the student during training (the paper measures this), providing targets slightly ahead of the student — a bootstrapping effect similar to BYOL's predictor asymmetry.
Q: Why do ViT attention maps segment objects without segmentation labels? The multi-crop objective forces local-crop features to predict global-view prototypes; the CLS token must aggregate from patches that determine object identity, so its attention concentrates on the foreground object. Supervised ViTs show much weaker localization — the objective, not the architecture, produces it.
Q: What changes in DINOv2? Scale (LVD-142M curated data), iBOT-style masked-patch prediction added to the loss, KoLeo regularization, register tokens to fix attention-map artifacts, and efficient implementations — the centering/sharpening core stays.
References
- Emerging Properties in Self-Supervised Vision Transformers (DINO)
- DINOv2: Learning Robust Visual Features without Supervision
- An Image is Worth 16x16 Words (ViT)
- Vision Transformers Need Registers
- Official DINO code
Lab 05 — Multimodal Bridge (CLIP → LLM Projection)
Phase: 02 — Model Architecture Mastery | Difficulty: ⭐⭐⭐⭐⭐ | Time: 4–5 hours
LLaVA's core insight: a 2-layer MLP is all it takes to connect a frozen vision encoder to a frozen LLM. This lab builds that bridge at toy scale, where the architecture, the freezing discipline, and the loss masking are identical to the 7B-parameter version.
What you build
Projector— the trainable 2-layer MLP mapping vision-feature space → LLM embedding space (the only component that trains; <0.25% of total parameters)MultimodalModel— frozen vision tower + frozen causal LM with visual tokens prepended to the text sequenceloss— next-token cross-entropy on text positions only, with the visual-token causal shift and prompt masking (ignore_index=-100)generate— greedy image-conditioned decoding
Frozen TinyVisionEncoder / TinyCausalLM stand-ins are provided with the same interfaces
as CLIP ViT-L/14 and TinyLlama, so the tests run in seconds with no downloads; swapping in
the real models is the extension.
Key concepts
| Concept | What to understand |
|---|---|
| Patch tokens, not CLS | LLaVA feeds all patch tokens to the LM — VQA needs spatial detail the pooled embedding discards |
| Freezing discipline | requires_grad_(False) + vision under no_grad — optimizer never sees frozen params, no grad buffers allocated |
| The causal shift | Logits at the LAST visual position predict the FIRST text token; off-by-one here is the classic multimodal bug (a test targets it) |
| Loss masking | Prompt/question positions set to -100 — grade the answer, not the echo |
| Stage-1 vs stage-2 | LLaVA trains projector-only on captions first, then unfreezes the LM for instruction tuning |
Files
| File | Purpose |
|---|---|
lab.py | Skeleton with # TODO markers |
solution.py | Complete reference; python solution.py shows projector-only training working |
test_lab.py | 12 tests: freezing, grad flow, logit alignment, masking, image→text information flow |
requirements.txt | torch, pytest |
Run
pip install -r requirements.txt
pytest test_lab.py -v
LAB_MODULE=solution pytest test_lab.py -v
Suggested TODO order
Projector— two lines of architecture, but know why MLP > single linearMultimodalModel.__init__— the freezing; checktest_only_projector_is_trainablevisual_tokens→forward— concatenation order mattersloss— draw the position layout on paper before coding the slicegenerate
Success criteria
- All 12 tests pass — especially
test_loss_alignment_uses_image_conditioned_prediction(the off-by-one catcher) andtest_trained_model_uses_the_image(proves information actually flows image → projector → LM) - You can state the trainable-parameter fraction and why that makes stage-1 training cheap
- You can explain what breaks if you
detach()the projector output instead of the vision output (no gradient reaches the projector — silent non-training)
Full-scale extension (the original spec)
Swap stand-ins for openai/clip-vit-large-patch14 (frozen) and TinyLlama-1.1B (frozen);
train the projector on the LLaVA-Instruct-150K caption subset; evaluate VQAv2 accuracy
(target >50%); compare single-linear vs MLP projector. Then: Q-Former connector (BLIP-2),
and measure the NPU-relevant cost — visual tokens inflate the KV cache by N_patches at
every layer.
Interview Q&A
Q: Why freeze both towers in stage 1? The projector starts random — its gradient noise would destroy pretrained representations (the LM would "catastrophically adapt" to garbage visual tokens). Train the bridge until visual tokens live in the LM's embedding distribution, then optionally unfreeze for instruction tuning.
Q: Why does LLaVA prepend visual tokens rather than cross-attend (Flamingo-style)? Prepending reuses the LM unchanged (no new cross-attention layers to train), is trivially compatible with KV caching and existing serving stacks, and scales with patch count. Cross-attention isolates modalities better and keeps text-only throughput intact, at the cost of new trained layers — a classic deployment-vs-architecture tradeoff.
Q: What is the inference cost of visual tokens on an edge NPU? Each of the N visual tokens occupies KV cache in every layer (N × layers × 2 × d_kv) and participates in every attention computation — for 576 CLIP tokens this often dominates a short-question VQA prompt. Mitigations: token pruning/merging, pooled visual tokens, Q-Former-style compression to a fixed small budget.
References
- Visual Instruction Tuning (LLaVA)
- Improved Baselines with Visual Instruction Tuning (LLaVA-1.5)
- BLIP-2: Bootstrapping Language-Image Pre-training
- Flamingo: a Visual Language Model for Few-Shot Learning
- CLIP
Phase 03 — Quantization & Model Compression
Difficulty: ⭐⭐⭐⭐⭐
Estimated Time: 4 weeks (90–110 hours)
Roles Supported: Core differentiating skill — every NPU deployment requires quantization; accuracy recovery under aggressive quantization is what Senior Staff engineers own
Why This Phase Exists
This is the skill that distinguishes Qualcomm ML engineers from generic ML engineers. A Qualcomm NPU (HTP) achieves peak performance with INT8/INT4 activations and weights. Moving from FP32 → INT8 gives ~4x memory reduction and ~4–8x compute speedup, but at what accuracy cost? The answer is: it depends entirely on how you quantize.
Naive quantization (round FP32 to INT8 with global min/max clipping) easily degrades MMLU from 72% to 55% for a 7B model. State-of-the-art quantization (GPTQ + activation-aware calibration) can maintain 70.8% — barely above FP16 baseline. The difference between these outcomes is what this phase teaches.
The JD requires "expertise in model training and inference workflows" with "graph optimizations for performance and accuracy." Quantization is the single most important accuracy-vs-performance tradeoff in the NPU deployment pipeline.
Concepts
- Uniform quantization: $Q(x) = \text{round}(x / s) + z$, where scale $s = (x_{max} - x_{min}) / (2^b - 1)$ and zero-point $z$ is the integer offset
- Per-tensor vs per-channel vs per-group quantization: coarser granularity → higher error; finer granularity → more overhead; group quantization (AWQ, GPTQ) balances both
- Symmetric vs asymmetric: symmetric ($z=0$, signed ints) simpler for hardware; asymmetric handles non-zero activation distributions better (important for ReLU outputs)
- Post-Training Quantization (PTQ): quantize after training using a calibration dataset to determine scales; fast but suboptimal
- Calibration strategies: MinMax, Percentile (discard outliers), Entropy/KL-divergence (minimize information loss), MSE minimization
- Quantization-Aware Training (QAT): insert fake-quantization nodes during training; gradients flow through straight-through estimator (STE); best accuracy
- Straight-Through Estimator (STE): $\frac{\partial \text{round}(x)}{\partial x} \approx 1$ in backward pass — enables gradients to flow through the non-differentiable round operation
- GPTQ (Generative Pre-trained Transformer Quantization): layer-wise quantization using second-order (Hessian) information; solves an optimal quantization problem per-layer; 4-bit models at near-FP16 accuracy
- AWQ (Activation-Weighted Quantization): identifies salient weights (high activation magnitude) and applies per-group scaling to protect them; simpler than GPTQ, hardware-friendly
- SmoothQuant: migrates quantization difficulty from activations to weights by multiplying activations by $1/s$ and weights by $s$; activations become easier to quantize
- FP8 quantization: used in H100/H200 and Qualcomm's latest chips; E4M3 or E5M2 format; lower quantization error than INT8 for many distributions
- Knowledge Distillation: train a student to match teacher logits/features; use for accuracy recovery after aggressive quantization
- Magnitude pruning: zero out weights below threshold; reduces model size but needs sparse hardware support for speedup
- Structured pruning: remove entire channels/heads/layers; immediately reduces compute; can be combined with quantization
Labs
Lab 01 — PTQ Pipeline with Accuracy Benchmarking
| Field | Value |
|---|---|
| Goal | Build a complete Post-Training Quantization pipeline for a small transformer (TinyLlama or OPT-125M) that: quantizes all linear layers to INT8/INT4, supports multiple calibration strategies, and reports perplexity/task accuracy before and after quantization |
| Concepts | Min/max calibration, percentile calibration, per-tensor vs per-channel, quantize/dequantize (QDQ) nodes, accuracy regression |
| Steps | 1. Implement QuantizedLinear module with per-channel INT8 quantization of weights; 2. Implement calibration hooks (capture activation statistics over 512 sample forward passes); 3. Implement MinMax, Percentile (99.9th), and KL-divergence calibration for activations; 4. Replace all nn.Linear in target model with QuantizedLinear; 5. Run calibration on WikiText-2 train set; 6. Evaluate perplexity on WikiText-2 test + MMLU 5-shot accuracy for each calibration strategy |
| Stack | PyTorch 2.3+, transformers, datasets, lm_eval |
| Datasets | WikiText-2, MMLU (via lm_eval) |
| Output | Accuracy comparison table: FP16 vs INT8 per-tensor vs INT8 per-channel vs INT4 per-channel across 3 calibration strategies; calibrated .pt model |
| How to Test | pytest test_lab.py — checks: quantized values fit in [-128,127] (INT8) or [-8,7] (INT4), QDQ round-trip error < 1% of weight range, perplexity degradation < 0.5 ppl for INT8 per-channel |
| Talking Points | Why is per-channel better than per-tensor? What is the KL-divergence calibration optimizing? When would you use percentile over min-max? |
| Resume Bullet | Built PTQ pipeline for OPT-125M with 4 calibration strategies; achieved 0.3 PPL degradation under INT8 per-channel vs FP16 baseline, enabling 3.2× memory reduction for NPU deployment |
| Extensions | Add rotation-based outlier suppression (QuaRot); implement per-group quantization (group_size=128); add accuracy CI comparison with statistical significance |
Lab 02 — QAT from Scratch
| Field | Value |
|---|---|
| Goal | Implement Quantization-Aware Training from scratch using fake-quantization nodes with Straight-Through Estimator; train a ResNet-18 on CIFAR-10 from QAT initialization (vs PTQ baseline) and demonstrate superior accuracy |
| Concepts | Fake quantization, STE, torch.quantization.FakeQuantize, ObserverBase, HistogramObserver, QAT fine-tuning schedule, fold-BN-into-conv before deployment |
| Steps | 1. Implement FakeQuant function with STE backward using torch.autograd.Function; 2. Implement MinMaxObserver that tracks running min/max and updates quantization parameters; 3. Implement QuantizationConfig with per-layer precision specification; 4. Insert fake-quant nodes after every Conv2d and before every activation; 5. Fine-tune ResNet-18 with QAT for 30 epochs on CIFAR-10; 6. Compare: FP32 (93.1%) → PTQ INT8 → QAT INT8; 7. Export to torch.jit.script with quantized ops folded |
| Stack | PyTorch 2.3+, torchvision |
| Datasets | CIFAR-10 |
| Output | Trained QAT model; accuracy table FP32 vs PTQ vs QAT (INT8 weights + INT8 activations); size comparison |
| How to Test | pytest test_lab.py — checks: STE gradient passes through round operation, observer statistics are monotonically updated, QAT model accuracy ≥ PTQ+1% on CIFAR-10 test set |
| Talking Points | Why does STE work despite being mathematically incorrect? When does QAT fail to help (hint: very low bits like INT2)? How do you schedule learning rates differently during QAT? |
| Resume Bullet | Implemented QAT from scratch with STE-based fake quantization; improved INT8 ResNet-18 accuracy by 2.4% over PTQ baseline (91.8% vs 89.4%) on CIFAR-10 |
| Extensions | Implement mixed-precision QAT (different layers at INT8/INT4); add learned step size quantization (LSQ); implement channel-wise quantization in QAT |
Lab 03 — GPTQ: Hessian-Based Optimal Quantization
The shipped lab (lab-03-gptq/) implements GPTQ end-to-end: Hessian computation, column-wise OBQ with Cholesky updates, error propagation, and a full model pipeline with calibration hooks. AWQ is implemented in Phase 10 — Lab 01. The AWQ/SmoothQuant/Pareto sweep described below is the extension target once both labs are done.
| Field | Value |
|---|---|
| Goal | Implement simplified versions of GPTQ, AWQ, and SmoothQuant for LLaMA-style models; run all three on LLaMA-3.2-1B at INT4 and INT8; plot accuracy-latency Pareto frontier |
| Concepts | Hessian-based optimal quantization (GPTQ), activation magnitude analysis (AWQ), scale migration (SmoothQuant), group quantization, accuracy vs throughput Pareto curve |
| Steps | 1. Implement SmoothQuant: collect activation max per-channel over calibration set; compute per-channel scale $s = \max(|X|) / \max(|W|)$; apply to W and X; 2. Implement AWQ: find top-1% salient weight channels (max activation × max weight); scale those channels; 3. Implement GPTQ: layer-by-layer; compute pseudo-Hessian from activations; update remaining unquantized weights to compensate for quantization error; 4. Evaluate all methods on WikiText-2 PPL + MMLU at INT4 and INT8; 5. Plot 2D Pareto curves (accuracy vs latency, accuracy vs bits, latency vs model size) |
| Stack | PyTorch 2.3+, transformers, lm_eval, matplotlib |
| Datasets | WikiText-2, MMLU, C4 (for calibration) |
| Output | 3-method comparison table at INT4/INT8; Pareto curve visualization; quantized model weights; end-to-end demo: python demo.py --model llama-3.2-1b --method gptq --bits 4 |
| How to Test | pytest test_lab.py — checks: SmoothQuant scales are per-channel not per-tensor, AWQ identifies correct salient channels (high act × high weight), GPTQ error decreases per layer, PPL ranking: FP16 < AWQ < GPTQ < SmoothQuant < PTQ at INT4 |
| Talking Points | Why does GPTQ use Hessian information? What does "activation-aware" mean in AWQ? Why does SmoothQuant help hardware but not always help accuracy? When would you choose AWQ over GPTQ? |
| Resume Bullet | Implemented GPTQ/AWQ/SmoothQuant for LLaMA-3.2-1B; achieved 6.1 PPL at INT4 (vs 5.8 FP16 baseline) with GPTQ — 4× memory reduction for NPU deployment with <5% accuracy loss |
| Extensions | Add SpQR (sparse + quantized) combination; implement GGML-style quantization (llama.cpp format); add FP8 E4M3 quantization path; run on Qualcomm AI Hub |
Deliverables Checklist
- PTQ pipeline with calibration comparison table on OPT-125M / TinyLlama
- QAT implementation with STE showing accuracy > PTQ on CIFAR-10
- GPTQ + AWQ + SmoothQuant comparison with Pareto curves on LLaMA-3.2-1B
-
All
test_lab.pysuites pass - Pareto curve plots saved as PNG artifacts
Guides in This Phase
- HITCHHIKERS-GUIDE.md — quantization math, GPTQ, AWQ, SmoothQuant, decision tree, key numbers
- DEEP-DIVE-QAT-GPTQ.md — PTQ, QAT/STE, and GPTQ from first principles (supports Labs 02–03)
Interview Relevance
- "Explain the Straight-Through Estimator and why it works." — after Lab 02, you can draw the backward graph and explain the approximation
- "How does GPTQ minimize quantization error?" — describe the Cholesky-based optimal weight update, the per-column sequential quantization, and the Hessian estimation
- "Given a model that loses 5% accuracy under INT4, what would you try first?" — you have a decision tree: SmoothQuant (free, helps activation outliers), AWQ (salient channels), GPTQ (optimal per-layer), QAT (expensive but best)
- "What is the memory layout consideration for per-group quantization on an NPU?" — group scales must be loaded alongside weights, understand the bandwidth implications
Warmup Guide — Quantization & Model Compression
Zero-to-expert primer for Phase 03. Builds quantization from "what is a number format" through PTQ, QAT, and GPTQ — assuming only basic Python and the Phase 02 architecture vocabulary.
Table of Contents
- Chapter 1: Number Formats — What Precision Actually Is
- Chapter 2: Why Quantize — The Memory Wall
- Chapter 3: Uniform Quantization — The Core Math
- Chapter 4: Granularity — Per-Tensor, Per-Channel, Per-Group
- Chapter 5: Calibration — Choosing the Range
- Chapter 6: PTQ — The Baseline Pipeline
- Chapter 7: QAT and the Straight-Through Estimator
- Chapter 8: GPTQ — Second-Order Quantization
- Chapter 9: The Activation Outlier Problem — AWQ and SmoothQuant in Brief
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Number Formats — What Precision Actually Is
Zero background: computers store real numbers in fixed-width binary. A floating-point number is sign × mantissa × 2^exponent: the exponent gives range, the mantissa gives precision within that range. Key formats:
| Format | Bits | Exponent | Mantissa | Range | Precision |
|---|---|---|---|---|---|
| FP32 | 32 | 8 | 23 | ±3.4e38 | ~7 decimal digits |
| FP16 | 16 | 5 | 10 | ±65504 | ~3 digits — overflows easily |
| BF16 | 16 | 8 | 7 | FP32's range | ~2 digits — never overflows where FP32 doesn't |
| INT8 | 8 | — | — | [-128, 127] | uniform steps |
| INT4 | 4 | — | — | [-8, 7] | 16 values total |
The crucial difference: float spacing is relative (numbers near 0 are dense, large numbers sparse); integer spacing is absolute (uniform grid). Quantization is the act of mapping the float world onto an integer grid — and choosing where that grid sits is the entire game.
Production reality: INT8/INT4 are not just smaller — NPU/GPU integer pipelines have 2–8× the throughput of float pipelines, and (more important on edge) memory traffic drops proportionally with bit-width. FP8 (E4M3/E5M2) is the newest middle ground on H100-class and recent NPU hardware.
Chapter 2: Why Quantize — The Memory Wall
A 7B-parameter model in FP16 is 14 GB of weights. To generate one token you must read every weight once — at batch 1, LLM decoding is memory-bandwidth bound, not compute-bound (Phase 07 formalizes this with roofline analysis). So:
$$\text{tokens/sec} \approx \frac{\text{memory bandwidth}}{\text{bytes of weights}}$$
Halve the bytes → roughly double the decode speed, independent of compute. INT4 cuts a 7B model to 3.5 GB — the difference between "doesn't fit on the phone" and "fits with room for the KV cache." That is why quantization is the edge-deployment skill, and why this phase exists.
The price: every weight moves to its nearest grid point. The question of this whole phase is: how much accuracy does that cost, and how do you minimize it? Naive INT8 can take a 7B model's MMLU from 72% to 55%; well-engineered INT4 can hold it within a point. The technique gap, not the bit-width, determines the outcome.
Chapter 3: Uniform Quantization — The Core Math
The affine map. Choose a scale $s$ and zero-point $z$:
$$q = \text{clamp}!\left(\text{round}!\left(\frac{x}{s}\right) + z,; q_{min},; q_{max}\right), \qquad \hat{x} = s,(q - z)$$
- $s$ = step size of the integer grid in real units: $s = (x_{max} - x_{min})/(2^b - 1)$.
- $z$ = which integer represents real 0. Symmetric quantization fixes $z = 0$ (grid centered on zero, signed ints) — simpler hardware, no zero-point multiplies in the matmul inner loop. Asymmetric allows $z \neq 0$ — fits one-sided distributions (post-ReLU activations live in $[0, +)$; symmetric wastes half the grid on negatives).
- Convention: weights symmetric per-channel, activations asymmetric per-tensor is the standard hardware-friendly starting point.
The two error sources:
- Rounding error: uniform in $[-s/2, s/2]$, variance $s^2/12$. Shrinks as range shrinks.
- Clipping error: values outside $[x_{min}, x_{max}]$ saturate. Shrinks as range grows.
These trade against each other through $s$ — that tension is calibration (Chapter 5). One outlier weight of 50 in a tensor whose other values live in $[-1, 1]$ forces $s$ 50× larger, multiplying everyone else's rounding error by 50. Outliers are the villain of this entire phase.
Quantized matmul: $Y = XW \approx s_x s_w (Q_x Q_w)$ — the inner product runs in integer arithmetic (accumulating in INT32), with scales applied once at the end. This is what NPU MAC arrays actually execute.
Chapter 4: Granularity — Per-Tensor, Per-Channel, Per-Group
How many values share one $(s, z)$ pair?
- Per-tensor: one scale for the whole weight matrix. Cheapest; one outlier row ruins every row.
- Per-channel: one scale per output row of $W$. Each row's range fits its own distribution — the single biggest free win for weights; supported by essentially all inference hardware.
- Per-group: one scale per contiguous group of (typically 128) weights within a row — the GPTQ/AWQ INT4 standard. Finer adaptation; cost = storing one FP16 scale per 128 INT4 values (≈ +3% memory).
Rule of thumb: INT8 weights → per-channel suffices. INT4 weights → per-group is required. Activations → per-tensor (dynamic per-token helps but costs runtime calibration).
Chapter 5: Calibration — Choosing the Range
Run a few hundred representative samples through the model, observe activation distributions, choose $[x_{min}, x_{max}]$ per quantizer:
- MinMax: exact observed extremes. Zero clipping, maximal rounding error; one outlier ruins it. Right for weights (no sampling noise — you can see all of them).
- Percentile (99.9th): clip the tail outliers deliberately. Usually beats MinMax for activations.
- Entropy / KL-divergence (TensorRT's classic): choose the clip threshold whose quantized distribution diverges least from the original — principled rounding-vs- clipping balance.
- MSE: directly minimize $|x - \hat{x}|^2$ over candidate clip values.
Engineering reality: calibration-set choice matters as much as method — it must cover the deployment input distribution. A model calibrated on English prose mis-ranges on code tokens. Lab 01 makes you implement three methods and measure the differences instead of trusting folklore.
Chapter 6: PTQ — The Baseline Pipeline
Post-Training Quantization = freeze the trained model, calibrate, quantize, ship. No gradients, minutes not days. The pipeline you build in Lab 01:
- Wrap target layers (
QuantizedLinearreplacingnn.Linear). - Attach observers; run calibration batches; collect ranges.
- Compute $(s, z)$ per quantizer from the chosen calibration rule.
- Quantize weights offline; activations quantize at runtime (or simulate with QDQ — quantize-dequantize — nodes for accuracy evaluation in float).
- Measure: perplexity and task accuracy vs the FP16 baseline, per configuration.
When PTQ suffices: INT8 per-channel on most transformer weights — typically <0.5 PPL degradation. When it fails: INT4 without grouping; activation quantization in the presence of outliers; small models (less redundancy to absorb error); anything where the calibration set misses the deployment distribution.
Chapter 7: QAT and the Straight-Through Estimator
The idea: if quantization will distort the network, let training see the distortion and route around it. Insert fake-quantize nodes — $\hat{x} = s(\text{round}(x/s) - z)$ computed in float — into the forward pass and fine-tune.
The problem: $\text{round}(\cdot)$ has derivative 0 almost everywhere (and undefined at half-integers). Backprop through it kills all gradients.
The Straight-Through Estimator: in the backward pass, pretend round is the identity:
$$\frac{\partial,\text{round}(x)}{\partial x} \overset{\text{STE}}{=} 1 \quad (\text{within the clip range; 0 outside})$$
Mathematically false; empirically it works because round is "identity plus bounded noise" ($|round(x) - x| \le 0.5$), so the identity's gradient points in approximately the right direction, and SGD's stochasticity absorbs the bias. The clip-range masking matters: saturated values get zero gradient (they genuinely can't move the output), which also nudges weights back inside the representable range.
What QAT buys: weights migrate toward grid points and away from clip boundaries during fine-tuning — typically +1–3% accuracy over PTQ at INT8, and it's the main tool that makes INT4/INT2 activations viable. Cost: a training loop, data, and time — which is why PTQ-family methods (GPTQ/AWQ) dominate for LLMs where fine-tuning is expensive.
Lab 02 implements FakeQuant as a torch.autograd.Function with the STE backward and an
observer that tracks running ranges — the same architecture as torch.ao.quantization's
internals.
Chapter 8: GPTQ — Second-Order Quantization
The question GPTQ answers: when you round one weight, can you adjust the other weights to compensate? Yes — optimally, using curvature information.
Setup: quantize layer-by-layer, minimizing layer output error $|XW - X\hat{W}|^2$ over the calibration activations $X$. The curvature of this objective w.r.t. the weights of one output row is the Hessian $H = 2XX^\top$ (+ small damping $\lambda I$ for stability) — note it depends only on inputs, shared across rows.
The OBQ step, for one weight $w_q$ (from the Optimal Brain Surgeon lineage): quantize it, then update all not-yet-quantized weights in the row by
$$\delta = -\frac{w_q - \text{quant}(w_q)}{[H^{-1}]{qq}} , [H^{-1}]{:,q}$$
— spread the rounding error onto correlated weights, weighted by inverse curvature (move most where the loss is flattest).
GPTQ's algorithmic contributions that make this feasible at LLM scale:
- Quantize columns in a fixed order for all rows (vectorizes across the matrix).
- Maintain $H^{-1}$ updates via Cholesky factorization (numerically stable; the naive rank-1 inverse updates accumulate error and produce garbage at 175B scale).
- Lazy-batch the updates for memory locality.
Result: a 175B model quantized to INT4/3-bit in hours on one GPU, at near-FP16 perplexity. Lab 03 implements the per-layer algorithm: Hessian from calibration hooks, column-wise quantize-and-compensate, and the comparison vs round-to-nearest that shows why the compensation matters (typically 2–5× lower reconstruction error).
Chapter 9: The Activation Outlier Problem — AWQ and SmoothQuant in Brief
Transformer activations past ~6B parameters develop systematic outlier channels — specific hidden dimensions with values 10–100× the median, consistently across tokens (an emergent property tied to attention "sink" behavior). Weights quantize easily; these activations don't.
- SmoothQuant: migrate difficulty from activations to weights with a per-channel rescale: $Y = (X,\text{diag}(s)^{-1})(\text{diag}(s),W)$ — mathematically identical, but $X' = X/s$ has tamed ranges while $W' = sW$ absorbs them (weights have headroom). Choose $s_j = \max|X_j|^\alpha / \max|W_j|^{1-\alpha}$, α ≈ 0.5.
- AWQ: observe that ~1% of weight channels are salient (they multiply the high-magnitude activations); protect them by scaling up before quantization (equivalent trick in the other direction). No Hessian, no backprop — hardware-friendly and fast. You implement AWQ fully in Phase 10 Lab 01.
These two plus GPTQ form the LLM INT4 toolbox; Chapter 8's deep dive file (DEEP-DIVE-QAT-GPTQ.md) covers the math at full depth.
Lab Walkthrough Guidance
Order: Lab 01 (PTQ) → Lab 02 (QAT) → Lab 03 (GPTQ) — each builds on the previous's vocabulary.
- Lab 01: implement
quantize/dequantizeand verify the round-trip error bound ($\le s/2$ per element) before touching calibration. Then observers, then the three calibration strategies, then the accuracy table. Resist evaluating before all three are in — the comparison is the lab. - Lab 02: write the STE
autograd.Functionfirst andgradcheckyour own understanding: the gradient should be 1 inside the clip range, 0 outside. Then the observer, then training. Verify QAT > PTQ on the final table. - Lab 03: build
compute_hessianand check positive-definiteness (damping!). Then single-column quantize-with-compensation on a toy 4×4 case you can verify by hand. Then the full loop, thencompare_reconstruction_error— GPTQ must beat round-to-nearest clearly.
Success Criteria
You are ready for Phase 04 when you can, from memory:
- Write the affine quantization map and derive $s$ from a range and bit-width; state the rounding-vs-clipping tension in one sentence.
- Explain why weights are symmetric-per-channel and activations asymmetric-per-tensor in standard deployments.
- State the STE, why it's wrong, and why it works anyway.
- Write GPTQ's compensation update and explain each factor ($[H^{-1}]_{qq}$ in the denominator = move most where curvature is flattest).
- Explain the activation-outlier phenomenon and the SmoothQuant rescaling identity.
- Given "MMLU dropped 5% at INT4", produce the ordered debugging tree: granularity → calibration → SmoothQuant/AWQ → GPTQ → QAT.
Interview Q&A
Q: Why does per-channel weight quantization dominate per-tensor? Output channels of a trained layer have wildly different weight ranges (often 10–50× spread). Per-tensor forces one scale to cover the largest channel, inflating everyone else's step size. Per-channel scales are free at inference (folded into the output rescale) — better accuracy at zero runtime cost.
Q: Decode throughput is memory-bound. Walk me through the INT4 speedup math. Decode reads all weights per token: $t \approx \text{bytes}/\text{BW}$. 7B FP16 = 14 GB; at 100 GB/s that's 140 ms/token ≈ 7 tok/s. INT4 (+group scales ≈ 3.6 GB) → ~28 tok/s. Compute barely changes; we cut bytes 4×. This is also why INT4 weights with FP16 compute (dequantize-on-load) still wins on bandwidth-bound hardware.
Q: When does QAT fail to help? At extreme bit-widths (INT2) where the function space is too coarse for STE's gradient fiction; when the fine-tuning data distribution drifts from deployment; and when the bottleneck is activation outliers (a range problem QAT's weight adaptation can't fix — you need SmoothQuant-style migration first).
Q: Why does GPTQ use the Hessian rather than weight magnitude? Magnitude says how big a weight is, not how much the output changes when you perturb it — that's curvature. $H = 2XX^\top$ measures exactly the output sensitivity under the calibration distribution, so error is spread onto directions the layer is least sensitive to. Magnitude-based rounding is precisely the "naive" baseline GPTQ beats.
Q: NPU memory layout implication of group quantization? Each group's FP16 scale must be co-resident with its INT4 weights during the MAC — layouts interleave scales with weight blocks so a tile fetch brings both; misalignment doubles memory transactions. group_size is chosen to match the NPU's native tile width (often 64 or 128) for exactly this reason.
References
- Jacob et al., Quantization and Training of Neural Networks for Efficient Integer-Arithmetic-Only Inference (2017) — arXiv:1712.05877
- Nagel et al., A White Paper on Neural Network Quantization (2021) — arXiv:2106.08295 — the single best survey
- Bengio et al., Estimating Gradients Through Stochastic Neurons (STE) (2013) — arXiv:1308.3432
- Frantar et al., GPTQ: Accurate Post-Training Quantization for GPT (2022) — arXiv:2210.17323
- Hassibi & Stork, Optimal Brain Surgeon (1992) — the lineage behind GPTQ
- Dettmers et al., LLM.int8(): 8-bit Matrix Multiplication and Emergent Outliers (2022) — arXiv:2208.07339
- Xiao et al., SmoothQuant (2022) — arXiv:2211.10438
- Lin et al., AWQ: Activation-aware Weight Quantization (2023) — arXiv:2306.00978
- PyTorch quantization docs
- DEEP-DIVE-QAT-GPTQ.md — this phase's companion deep dive
Hitchhiker's Guide — Quantization & Model Compression
"Quantization is not rounding. It is finding the best integer world to map the floating-point world into."
1. Quantization Math — Precise Derivations
1.1 Affine (Asymmetric) Quantization
$$Q(x) = \text{clamp}\left(\text{round}\left(\frac{x}{s}\right) + z, ; q_{\min}, q_{\max}\right)$$
$$x_{\text{dequant}} = s \cdot (Q(x) - z)$$
Scale and zero-point derivation from observed range $[x_{\min}, x_{\max}]$:
$$s = \frac{x_{\max} - x_{\min}}{q_{\max} - q_{\min}}, \qquad z = q_{\min} - \text{round}\left(\frac{x_{\min}}{s}\right)$$
For INT8: $q_{\min} = -128, q_{\max} = 127$ (signed) or $0, 255$ (unsigned).
Symmetric quantization ($z = 0$): $s = \frac{\max(|x|)}{q_{\max}}$. Simpler hardware: multiplication by scale instead of multiply + add.
1.2 Quantization Error
The quantization error (rounding error) for value $x$ is bounded by:
$$|Q(x) - x| \leq \frac{s}{2}$$
Therefore, smaller scale → smaller error. This is why per-channel quantization (separate scale per output channel) outperforms per-tensor (single scale): each channel can have its own optimal scale.
Signal-to-Quantization-Noise Ratio (SQNR):
$$\text{SQNR} \approx 6.02 b + 4.77 \text{ dB}$$ (for Gaussian-distributed values, $b$ = number of bits)
INT8 ≈ 52.9 dB SQNR; INT4 ≈ 28.9 dB SQNR. This is a 24 dB drop — a factor of ~250 in noise power.
1.3 Per-Group Quantization
In group quantization (used by GPTQ, AWQ, llama.cpp), weights are quantized in groups of $G$ elements sharing one (scale, zero-point):
$$Q(W_{i, j:j+G}) = \text{round}(W_{i, j:j+G} / s_{i,j})$$
Trade-off: $G=128$ → 1 extra FP16 scale per 128 INT4 weights = 3.1% overhead, but ~2-3 dB SQNR improvement over per-tensor.
Hardware consideration for Qualcomm HTP: HTP natively supports INT4 with per-group scales at G=32, G=64, G=128. G=32 is most accurate but requires more scale storage; G=128 is most hardware-friendly.
2. GPTQ — Hessian-Based Optimal Quantization
2.1 The Core Idea
GPTQ frames weight quantization as an optimization problem. For a layer with weight $W$ and input activations $X$:
$$\min_{\hat{W}} |WX - \hat{W}X|_F^2$$
subject to $\hat{W}$ being quantized.
The optimal update when quantizing weight $w_q$ to $\hat{w}_q$ is:
$$\delta W = -\frac{w_q - \hat{w}q}{[H^{-1}]{qq}} \cdot H^{-1}_{:,q}$$
where $H = 2X X^T$ is the (approximated) Hessian of the squared error.
Key insight: after quantizing one weight, update all remaining unquantized weights to compensate for the introduced error. This is the same idea as "least squares with constraints" — each quantization decision corrupts the loss function, and GPTQ corrects for it greedily.
2.2 Cholesky Implementation
Direct Hessian inversion is $O(d^3)$ — expensive for large layers. GPTQ uses the Cholesky decomposition trick: process columns in blocked order using triangular solve, achieving $O(d^2)$ amortized.
def gptq_quantize_layer(W, X, bits=4, group_size=128, block_size=128):
"""
W: (out_features, in_features)
X: (in_features, n_samples) — calibration activations
Returns: W_quantized, scales, zeros
"""
d = W.shape[1]
H = 2 * (X @ X.T) # (in_features, in_features)
# Add damping for numerical stability
H += 0.01 * torch.diag(H).mean() * torch.eye(d, device=H.device)
# Cholesky decomposition for stable inversion
L = torch.linalg.cholesky(H)
H_inv = torch.cholesky_inverse(L)
W_q = W.clone()
scales = []
zeros = []
for i in range(0, d, block_size):
block_end = min(i + block_size, d)
for j in range(i, block_end):
# Find quantization params for this column group
g_start = (j // group_size) * group_size
w_col = W_q[:, j]
scale, zero = compute_qparams(w_col, bits)
# Quantize this column
w_q = quantize(w_col, scale, zero, bits)
# Compute error
err = (w_col - w_q) / H_inv[j, j]
# Update remaining columns to compensate
W_q[:, j+1:block_end] -= err.unsqueeze(1) * H_inv[j, j+1:block_end].unsqueeze(0)
W_q[:, j] = w_q
return W_q, scales, zeros
3. AWQ — Activation-Weighted Quantization
3.1 Salient Weight Detection
AWQ's key observation: not all weights are equally important. Weights corresponding to input channels with large activation magnitudes are disproportionately impactful on model accuracy.
$$\text{importance}(j) = \mathbb{E}{x \sim D}[|x_j|] \cdot |W{:,j}|_2$$
For the top-1% most important channels, quantization error multiplies by the (large) activation magnitude. For the bottom 99%, quantization error is scaled down.
3.2 The Scaling Trick
For input channel $j$ with scale $s_j$:
$$y = xW = (x \cdot s) \cdot (W / s) = x' \cdot W'$$
By choosing $s_j > 1$ for salient channels: $W'{:,j} = W{:,j} / s_j$ → smaller magnitude → less quantization error (fits better in the quantization grid). $x'_j = x_j \cdot s_j$ → but this is a multiplication of activations, which can be absorbed into the previous layer's weights (no extra compute).
Critical: the scale $s_j$ is chosen to minimize quantization error of $W'_{:,j}$ on the quantization grid, not based on a fixed formula. AWQ uses grid search: $s_j \in {1, 2, 4, 8, \ldots}$.
4. SmoothQuant — Migrating Difficulty from Activations to Weights
4.1 The Outlier Problem
LLMs have severe activation outliers: certain token positions and embedding dimensions have values 100× larger than typical. These outliers destroy per-tensor activation quantization (the scale becomes too large, compressing most values to a small range of integers).
Example: Activation channel 1053 in OPT-13B might have max value 3847 while 99% of channels are below 4. Per-tensor scale = 3847/127 = 30.3. Channel 0 with value 1.2 → quantizes to round(1.2/30.3) = 0. Massive quantization error.
4.2 The Per-Channel Scale Migration
For matrix multiply $y = xW$, introduce per-input-channel scales $s_j$:
$$y = x \cdot W = \underbrace{(x / s)}{\text{easy to quantize}} \cdot \underbrace{(W \cdot s)}{\text{absorb into weights}}$$
Scale is chosen as:
$$s_j = \frac{\max(|X_{:,j}|)^\alpha}{\max(|W_{j,:}|)^{1-\alpha}}, \quad \alpha = 0.5 \text{ (default)}$$
This migrates the quantization challenge from activations (hard to quantize, dynamic) to weights (easy to quantize, static, absorb offline).
Result: activation quantization improves dramatically; weight quantization barely worsens (weights are already well-distributed).
5. QAT and the Straight-Through Estimator
5.1 Why the Round Function Breaks Gradients
forward: x → round(x/s) * s → quantized_x
backward: d_loss/d_x = d_loss/d_quantized_x × d_quantized_x/d_x
= 0 (everywhere)
round() has derivative 0 almost everywhere (flat function with unit jumps). Gradient is zero → no learning signal. QAT is stuck.
5.2 STE: The Principled Hack
The Straight-Through Estimator approximates:
$$\frac{\partial \text{round}(x)}{\partial x} \approx \mathbf{1}{q{\min} \leq x \leq q_{\max}}$$
In practice, this means: just pass the gradient through as if quantization didn't happen (but zero it outside the quantization range).
class FakeQuantize(torch.autograd.Function):
@staticmethod
def forward(ctx, x, scale, zero_point, quant_min, quant_max):
# Actual quantization (for forward accuracy)
x_int = torch.round(x / scale) + zero_point
x_int = torch.clamp(x_int, quant_min, quant_max)
x_dequant = (x_int - zero_point) * scale
# Save mask for backward (STE clips gradient outside range)
ctx.save_for_backward(x, torch.tensor([quant_min * scale]),
torch.tensor([quant_max * scale]))
return x_dequant
@staticmethod
def backward(ctx, grad_output):
x, x_min, x_max = ctx.saved_tensors
# STE: pass gradient through, clip outside quantization range
mask = (x >= x_min) & (x <= x_max)
grad_input = grad_output * mask.float()
return grad_input, None, None, None, None
Why does it work despite being wrong? The approximation error in the gradient is small relative to the overall optimization landscape. The model learns to arrange its weights such that most values are in the quantization-friendly zone — which is exactly the goal.
6. Quantization Decision Tree (Interview Cheatsheet)
Given: model M, target device D, accuracy budget A, latency budget L
1. Can you afford QAT? (requires retraining time + labeled data)
YES → QAT (best accuracy, especially for INT4)
NO → go to 2
2. Are activation outliers severe? (check: max(|X|) / median(|X|) > 100)
YES → Apply SmoothQuant first (free, offline)
Then → go to 3
3. Target bitwidth?
INT8 → PTQ with per-channel KL-divergence calibration (usually <0.5 PPL loss)
INT4 → AWQ if latency matters, GPTQ if accuracy matters, both if you have time
4. Accuracy still insufficient after step 3?
→ Add Knowledge Distillation: quantized model learns from FP16 teacher
→ Try mixed-precision: INT8 for sensitive layers (first/last, embedding), INT4 for others
→ Increase group size (G=32 vs G=128)
5. Deploy to Qualcomm HTP?
→ Use QNN SDK quantization workflow on top of your quantized model
→ Enable HTP-specific optimizations (HTA = Hexagon Tensor Accelerator weight layout)
7. Key Accuracy Numbers to Know
| Method | LLaMA-7B WikiText-2 PPL | MMLU 5-shot | Notes |
|---|---|---|---|
| FP16 baseline | 5.68 | 67.9% | Reference |
| INT8 per-tensor | 6.12 | 62.1% | Naive PTQ |
| INT8 per-channel (KL calib) | 5.71 | 67.4% | Good PTQ |
| INT4 PTQ naive | 8.94 | 48.3% | Degraded |
| INT4 GPTQ (g=128) | 6.09 | 66.8% | Near FP16 |
| INT4 AWQ (g=128) | 6.24 | 66.2% | Simpler |
| INT4 QAT | 5.98 | 67.1% | Best, expensive |
8. Resources
- GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers (Frantar et al., 2022)
- AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration (Lin et al., 2023)
- SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models (Xiao et al., 2022)
- LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale (Dettmers et al., 2022) — the mixed-precision INT8 approach used by bitsandbytes
- Quantization and Training of Neural Networks for Efficient Integer-Arithmetic-Only Inference (Jacob et al., 2018) — the Google QAT paper
- PyTorch Quantization Documentation
- Qualcomm AI Hub — Quantization Guide
👨🏻 Brother Talk — Phase 03, Off the Record
Quantization — the phase where accuracy goes to die, and where a Senior Staff engineer earns their title by keeping it alive. This is the heart of the Qualcomm job.
Brother, if there's one phase that is the job, it's this one. "Model Accuracy and AI Performance" — quantization is exactly where accuracy and performance collide. The whole reason Qualcomm exists in this space is to run big models on small power budgets, and the only way to do that is to drop precision. Every. Single. Model. Gets quantized before it ships to a phone. So the engineer who can quantize a model to INT8 (or INT4!) without tanking accuracy is the engineer the whole org depends on. This is your bread and butter. Master it.
Here's the first truth that reframes everything: quantization is not rounding. It's finding the best integer world to map the floating-point world into. The scale and zero-point aren't trivia — they ARE the design. Get the range wrong (let one outlier blow up max), and your scale is huge, your steps are coarse, and everything important gets crushed into a few levels. The entire art is: pick the clipping range that minimizes the error on the values that matter. Per-tensor vs per-channel, symmetric vs asymmetric, percentile clipping vs min/max — these are all answers to "what range do I quantize over?" Build the PTQ pipeline and watch what a single outlier does to your accuracy. That's the lesson.
Now the bug I want tattooed on your brain, because I literally just fixed a version of it in
this track's QAT lab: the dtype you store the integer in must hold the range you quantize to.
8-bit unsigned quantization lives in [0, 255]. If you cast that to int8 (which is [-128,
127]) before you dequantize, every value above 127 wraps to negative and your weights become
garbage — the model's output explodes. It's a one-line mistake and it produces a
catastrophic-looking failure that looks like a deep numerical problem but is actually a
type-overflow footgun. This is the texture of real quantization work: tiny, mechanical mistakes
with huge blast radius. Be paranoid about ranges and dtypes.
The PTQ vs QAT decision is a core Senior Staff judgment call, so internalize it: PTQ (post-training quantization) is fast, needs only a little calibration data, and is your first move — often it's enough, especially at INT8. QAT (quantization-aware training) simulates quantization during training (fake-quant in the forward, straight-through estimator for the gradient) so the model learns to be robust to it — you reach for it when PTQ drops too much accuracy, especially at INT4 or for sensitive layers. The mistake juniors make is jumping to QAT (expensive, needs the training pipeline) when a better PTQ calibration would've done it. The principal move is: try PTQ hard first, reach for QAT when the accuracy budget forces you.
GPTQ is the one that'll make you valuable for LLMs specifically. It's not just "round the weights" — it's a second-order method that quantizes weights column by column while compensating the remaining weights for the error introduced, using the Hessian (approximated from calibration data). That error-compensation is why GPTQ holds accuracy at 4-bit where naive rounding falls apart. Understand the intuition — "quantize, measure the damage, push the correction into the not-yet-quantized weights" — and you understand the whole modern weight-only quantization family (GPTQ, AWQ, which you'll meet again in P10).
The thing nobody tells you about quantization accuracy: it's not uniform across the model. Some layers (often the first, the last, the attention output projections, the layernorms) are sensitive — quantizing them costs you dearly — while others shrug it off. Mixed precision (keep the sensitive layers high, crush the rest) is where the real accuracy/size wins live. The sensitivity analysis — quantize one layer at a time, measure the accuracy hit, rank them — is a technique that feels tedious and is actually the highest-leverage thing you can do. The Pareto frontier of "which layers in what precision" is a Senior Staff deliverable.
And the part specific to this job: hardware quantization is not textbook quantization. The NPU has opinions — it wants per-tensor or per-channel in specific layouts, specific bit-widths, specific symmetric/asymmetric conventions, and it has a fixed-point accumulator that can overflow if you're not careful. The quantization that's mathematically optimal might be the one the hardware can't run, or runs slowly. P06 makes this concrete; here, just plant the seed: always ask "what quantization does the silicon actually want?"
Career truth, brother: quantization expertise is rare and in brutal demand, because it sits at the intersection of ML math, numerical precision, and hardware — most people are strong in at most one. The engineer who can take a model from FP32 to INT4 with a 1% accuracy drop and explain every decision is writing their own ticket. This is the phase to go deep, be paranoid about ranges and dtypes, and build the sensitivity intuition that makes you the person they bring the hard models to.
Build the PTQ pipeline. Break it with an outlier. Fix the QAT overflow yourself if you want to feel it. Then come to P04, where we stop hand-optimizing and let the compiler do it — torch.compile and the FX graph.
— your brother 👨🏻
Deep Dive — PTQ, QAT & GPTQ from First Principles (Phase 03)
"PTQ gets you to INT8 for free. QAT gets you to INT4 with effort. GPTQ gets you to INT4 with math."
Section 1: Why Quantization Exists — The Memory Wall
A 7B parameter model in FP32 = 7 × 10⁹ × 4 bytes = 28 GB VRAM. A single A100 has 80 GB; an iPhone 15 Pro has 8 GB. Without quantization, large models simply don't fit on the hardware where they need to run.
Quantization converts floating-point weights (and optionally activations) to lower-bit integers:
| Format | Bits | Size reduction | Memory (7B params) | Typical accuracy loss |
|---|---|---|---|---|
| FP32 | 32 | 1× | 28 GB | Reference |
| FP16/BF16 | 16 | 2× | 14 GB | <0.1% |
| INT8 | 8 | 4× | 7 GB | 0.1–0.5% |
| INT4 | 4 | 8× | 3.5 GB | 0.5–2% (with good algo) |
| INT4 (naive) | 4 | 8× | 3.5 GB | 5–15% — unusable |
The difference between "0.5%" and "15%" INT4 accuracy loss is entirely in the algorithm. That's why QAT and GPTQ exist.
Section 2: Post-Training Quantization (PTQ) — The Baseline
2.1 Symmetric Quantization Math
Symmetric per-tensor (the simplest form):
Given weights $W \in \mathbb{R}^{m \times n}$ and target bit width $b$:
$$\text{max_int} = 2^{b-1} - 1 \quad (\text{e.g., 127 for INT8, 7 for INT4})$$
$$\text{scale} = \frac{\max(|W|)}{\text{max_int}}$$
$$W_{\text{int}} = \text{round}\left(\frac{W}{\text{scale}}\right) \text{ clamped to } [-\text{max_int}, \text{max_int}]$$
$$W_{\text{deq}} = W_{\text{int}} \times \text{scale}$$
Example with INT4 (max_int = 7):
W = tensor([[0.23, -1.45, 0.07, 2.10],
[-0.89, 0.51, 1.33, -0.42]])
abs_max = 2.10
scale = 2.10 / 7 = 0.300
W_int = round(W / 0.300) = [[1, -5, 0, 7],
[-3, 2, 4, -1]] # clamped to [-7, 7]
W_deq = W_int * 0.300 = [[0.30, -1.50, 0.00, 2.10],
[-0.90, 0.60, 1.20, -0.30]]
Error = W - W_deq = [[-0.07, 0.05, 0.07, 0.00],
[0.01, -0.09, 0.13, -0.12]]
The maximum error is bounded by scale/2 = 0.15 (half the quantization step).
2.2 Asymmetric Quantization
Symmetric works when weights are centered around zero. Activations (after ReLU, GELU) are often non-negative — half the INT range would be wasted.
Asymmetric per-tensor:
$$\text{n_levels} = 2^b \quad (\text{e.g., 256 for INT8})$$
$$\text{scale} = \frac{\max(W) - \min(W)}{\text{n_levels} - 1}$$
$$\text{zero_point} = \text{round}\left(\frac{-\min(W)}{\text{scale}}\right) \text{ clamped to } [0, \text{n_levels}-1]$$
$$W_{\text{int}} = \text{round}\left(\frac{W}{\text{scale}} + \text{zero_point}\right) \text{ clamped to } [0, \text{n_levels}-1]$$
$$W_{\text{deq}} = (W_{\text{int}} - \text{zero_point}) \times \text{scale}$$
Example with INT8 and non-negative activations:
Activations after ReLU: min=0.0, max=3.2
scale = (3.2 - 0.0) / 255 = 0.01255
zero_point = round(-0.0 / 0.01255) = 0
For value 1.6:
W_int = round(1.6 / 0.01255 + 0) = round(127.5) = 128
W_deq = (128 - 0) * 0.01255 = 1.606
Error = 0.006 — very small
With symmetric: min=0 would map to -127, wasting half the range.
2.3 Per-Tensor vs Per-Channel
Per-tensor: one scale for entire weight matrix. Simple but inaccurate for weights with very different magnitudes across output channels.
Per-channel: one scale per output channel (row of weight matrix). Much better for weights:
# Weight matrix W: [out_features, in_features]
# Per-channel: compute max abs per row
max_per_channel = W.abs().max(dim=1).values # [out_features]
scale = max_per_channel / 127.0 # [out_features]
# Quantize row-by-row (broadcast)
W_int = (W / scale.unsqueeze(1)).round().clamp(-127, 127)
W_deq = W_int * scale.unsqueeze(1)
Why this matters: A transformer's linear layers often have output channels spanning 0.01–10.0 in weight magnitude. Per-tensor scale = 10.0/127 = 0.079, so small-magnitude channels are quantized with massive relative error. Per-channel fixes this.
Section 3: Quantization-Aware Training (QAT)
3.1 The Core Problem with PTQ
PTQ quantizes after training is complete. The model was never trained to be robust to quantization noise. This is fine for INT8 but catastrophic for INT4:
INT8 quantization noise ~ 0.5% of weight range → small perturbation
INT4 quantization noise ~ 7% of weight range → very large perturbation
At INT4, each weight is rounded to one of only 16 values. A model trained in FP32 never experienced this level of discretization and its outputs change dramatically.
Solution: Train the model while simulating quantization. The model learns to be robust.
3.2 The Straight-Through Estimator (STE)
The quantization function $Q(x) = \text{round}(x/s) \times s$ has zero gradient almost everywhere and undefined gradient at step discontinuities. We can't backprop through it.
STE trick (Hinton, Bengio et al.): approximate $\partial Q(x)/\partial x \approx 1$ (pass gradient straight through):
$$\text{FakeQuantize}(x) = x + \underbrace{(Q(x) - x)}{\text{quantization error}} \cdot \underbrace{\mathbf{1}}{\text{stop-gradient}}$$
In PyTorch:
def fake_quantize(x, scale, zero_point, n_levels):
"""Forward: quantize + dequantize. Backward: identity gradient."""
# Quantize
x_int = (x / scale + zero_point).round().clamp(0, n_levels - 1)
# Dequantize
x_dq = (x_int - zero_point) * scale
# STE: x_out = x + (x_dq - x).detach()
# Forward: x_out = x_dq (quantized values)
# Backward: ∂x_out/∂x = ∂x/∂x = 1 (gradient flows through unchanged)
return x + (x_dq - x).detach()
Why this works: During forward pass, the model sees quantized values — it experiences the quantization error and must produce correct output despite it. During backward pass, the gradient flows as if quantization didn't happen — weights still get useful gradient signal to improve.
Concrete gradient walkthrough:
import torch
x = torch.tensor([1.7], requires_grad=True)
scale = 0.5
zero_point = 0
n_levels = 256
# Forward
x_int = (x / scale + zero_point).round().clamp(0, 255) # = round(3.4) = 3
x_dq = (x_int - zero_point) * scale # = 3 * 0.5 = 1.5
x_out = x + (x_dq - x).detach() # = x + (1.5 - 1.7).detach()
# = x + (-0.2) [no grad to -0.2]
# x_out = 1.5 (numerically)
loss = x_out ** 2 # = 2.25
loss.backward()
print(x.grad) # = 2 * x_out * dx_out/dx
# = 2 * 1.5 * 1 ← STE: dx_out/dx = 1
# = 3.0
# NOT: 2 * 1.5 * 0 = 0 (what you'd get from round)
Without STE: x.grad = 0.0 → no learning signal. With STE: x.grad = 3.0 → normal gradient.
3.3 QAT Training Loop
def train_qat(model, train_data, n_epochs=3, lr=1e-3, bits=8):
"""
Full QAT training loop.
The key: wrap model BEFORE creating optimizer.
Then train normally — FakeQuantize handles the rest.
"""
# Step 1: Replace all Linear layers with QATLinear (FakeQuantize on weights)
qat_model = wrap_model_for_qat(model, bits=bits)
# Step 2: Create optimizer — ALL parameters trainable
# (the base weight learns to minimize error despite quantization)
optimizer = torch.optim.AdamW(qat_model.parameters(), lr=lr)
losses = []
for epoch in range(n_epochs):
epoch_loss = 0.0
for x, y_true in train_data:
optimizer.zero_grad()
# Forward: weights go through FakeQuantize
# → model sees INT8-like values
# → must produce accurate output
y_pred = qat_model(x)
loss = F.mse_loss(y_pred, y_true)
# Backward: STE allows gradients to flow
# → weights update to reduce quantization sensitivity
loss.backward()
optimizer.step()
epoch_loss += loss.item()
losses.append(epoch_loss / len(train_data))
return qat_model, losses
What the model learns during QAT:
- Weight smoothing: weights tend toward values that quantize well (multiples of the scale)
- Reduced sensitivity: activations become less sensitive to weight perturbations (the model learns to be more robust)
- Calibrated ranges: the FakeQuantize scale adapts to actual weight distribution
3.4 Converting QAT Model to INT8
After QAT training, the model's weights are FP32 but trained to be robust to INT8 quantization. To actually save memory, we need to extract the integer weights:
def convert_to_int8(qat_model):
"""
After QAT training:
1. For each QATLinear layer:
a. Read current weight (FP32, but quantization-friendly)
b. Compute scale/zero_point from actual min/max
c. Store W_int (INT8 tensor)
2. Replace QATLinear → nn.Linear with dequantized weights
(in real deployment: use true INT8 GEMM kernel)
"""
converted = copy.deepcopy(qat_model)
quant_info = {}
for name, module in list(converted.named_modules()):
if isinstance(module, QATLinear):
W = module.weight.data
fq = module.fake_q
# Compute quantization parameters
min_val, max_val = W.min(), W.max()
scale = (max_val - min_val) / (fq.n_levels - 1)
zp = (-min_val / scale).round().clamp(0, fq.n_levels-1).int()
# Quantize to integers
W_int = (W / scale + zp).round().clamp(0, fq.n_levels-1).to(torch.int8)
# Dequantize (for our CPU reference implementation)
W_deq = (W_int.float() - zp) * scale
# Save quantization metadata
quant_info[name] = QuantizedWeight(W_int, scale.item(), zp.item(), fq.bits)
# Replace module with standard Linear using dequantized weights
# (in production: use torch.ao.nn.quantized.Linear with INT8 GEMM)
new_linear = nn.Linear(W.shape[1], W.shape[0], bias=module.bias is not None)
new_linear.weight = nn.Parameter(W_deq)
# ... set on parent module
return converted, quant_info
Memory layout in true INT8 deployment:
FP32 Linear weight: [4096, 4096] × 4 bytes = 64 MB
INT8 Linear weight: [4096, 4096] × 1 byte = 16 MB ← 4× smaller
Scale: [4096] × 4 bytes = 16 KB (per-channel scale, negligible)
During inference:
x: FP16 → INT8 quantize
W: INT8 (stored)
GEMM: INT8 × INT8 → INT32 accumulator (fast on Qualcomm HTP/CUDA INT8 cores)
Output: INT32 → dequantize → FP16
3.5 QAT vs PTQ: When to Use Each
| Scenario | Use PTQ | Use QAT |
|---|---|---|
| INT8 target, any model | ✅ | Overkill |
| INT4 target, BERT/RoBERTa | Maybe | ✅ |
| INT4 target, LLM (7B+) | Only GPTQ | ✅ if you have 7B training budget |
| Tight latency + accuracy | Measure first | ✅ if PTQ insufficient |
| Limited calibration data | ✅ (100 samples) | ❌ (needs full training data) |
| Time budget < 1 hour | ✅ | ❌ |
Section 4: GPTQ — Second-Order Quantization
4.1 The Core Insight
PTQ quantizes each weight independently: $W_q[i,j] = \text{round}(W[i,j] / s) \times s$.
This ignores correlations. But the reconstruction objective is:
$$\min_{W_q} |W \cdot X - W_q \cdot X|_F^2$$
We want the output of the layer to be close, not the weights themselves. Some weight perturbations cancel out, others amplify. GPTQ uses the Hessian of this objective to make smarter quantization decisions.
4.2 The Hessian of Layer Reconstruction
For the layer output $WX$ (W is the weight matrix, X is the input data), the reconstruction loss is:
$$L(W_q) = |WX - W_q X|_F^2$$
The gradient: $\nabla_{W_q} L = -2(W - W_q)X^T$
The Hessian: $H = 2XX^T$
This Hessian captures how much the output changes when we perturb each weight. Large eigenvalues = that direction of weight space strongly affects output. Small eigenvalues = we can change those weights without hurting accuracy.
def compute_hessian(X):
"""
X: [N, d_in] — N examples of layer inputs
Returns H: [d_in, d_in]
"""
# H = 2 * X^T @ X (in the form X is [d_in, N] convention)
Xt = X.T.float() # [d_in, N]
H = 2.0 * Xt @ Xt.T # [d_in, d_in]
# Numerical stabilization: add damping
# dampening = λ_mean * 0.01 prevents near-singular H
damp = 0.01 * H.diag().mean()
H += damp * torch.eye(H.shape[0])
return H
# Example:
# Network input x with batch=100 samples, d_in=32
X = torch.randn(100, 32)
H = compute_hessian(X)
# H is 32×32 positive definite symmetric matrix
# H[i,i] ≈ 2 * sum_k x_k[i]^2 = variance of input dimension i
Intuition: $H_{ii}$ tells us how sensitive the reconstruction loss is to weight column $i$. If the $i$-th input feature is always near-zero (small $H_{ii}$), quantizing that column's weights doesn't matter much. If $H_{ii}$ is large (that feature is active and important), quantization error there hurts a lot.
4.3 Optimal Brain Quantizer (OBQ) — The Algorithm
Problem: Given weight matrix $W \in \mathbb{R}^{d_{out} \times d_{in}}$ and Hessian $H$, find quantized $W_q$ minimizing $|WX - W_q X|_F^2$.
OBQ idea (one column at a time):
For column $j$ (processing in order $j = 0, 1, \ldots, d_{in}-1$):
- Quantize column $j$: $w_q^{(j)} = \text{round}(W[:, j] / s) \times s$
- Compute quantization error: $\delta^{(j)} = W[:, j] - w_q^{(j)}$
- Update remaining unquantized columns to compensate: $$W[:, j'] \leftarrow W[:, j'] - \delta^{(j)} \cdot \frac{H_{j, j'}^{-1}}{H_{jj}^{-1}}$$
The update step propagates the error to remaining columns in a way that minimizes the total reconstruction loss. This is the "brain surgery" analogy from OBS (Optimal Brain Surgeon): removing (quantizing) one weight, then adjusting others to compensate.
4.4 GPTQ: OBQ Made Practical
OBQ processes columns in arbitrary order (choosing the column with minimum quantization error each time) — $O(d_{in}^2)$ work per column, $O(d_{in}^3)$ total. Too slow for LLMs.
GPTQ simplification:
- Process columns left-to-right (no selection overhead)
- Use Cholesky decomposition of $H^{-1}$ for numerically stable updates
def gptq_quantize_layer(W, H, bits=4):
"""
W: [d_out, d_in] weight matrix
H: [d_in, d_in] Hessian from calibration data
"""
W = W.clone().float()
d_out, d_in = W.shape
# Step 1: Compute H^{-1} via Cholesky decomposition
# torch.linalg.cholesky gives L such that H = L @ L^T (lower triangular)
try:
L = torch.linalg.cholesky(H)
I = torch.eye(d_in, device=H.device)
H_inv = torch.cholesky_solve(I, L) # H^{-1} = L^{-T} L^{-1}
except:
H_inv = torch.linalg.inv(H) # fallback
W_q = W.clone()
# Step 2: Process columns left-to-right
for j in range(d_in):
# Quantize column j
w_col = W[:, j] # [d_out]
w_q_col = quantize_symmetric(w_col, bits)
W_q[:, j] = w_q_col
error = w_col - w_q_col # [d_out] quantization error
h_inv_j = H_inv[j, j+1:] # [d_in - j - 1] off-diagonal
h_inv_jj = H_inv[j, j].clamp(min=1e-8) # diagonal element
# Propagate error to remaining columns:
# W[:, j+1:] -= error.outer(h_inv_j) / h_inv_jj
if j + 1 < d_in:
W[:, j+1:] -= (error.unsqueeze(1) * h_inv_j.unsqueeze(0)) / h_inv_jj
return W_q
Step-by-step walkthrough with 1D output (d_out=1) and 4 input features:
W = [0.5, 1.2, -0.3, 0.8] # original weights
H_inv = [[0.4, -0.1, 0.0, 0.2], # inverse Hessian
[-0.1, 0.3, 0.1, -0.1],
[0.0, 0.1, 0.5, 0.1],
[0.2, -0.1, 0.1, 0.6]]
bits=4, scale = max(|W|)/7 = 1.2/7 ≈ 0.171
Step j=0:
w[0] = 0.5
w_q[0] = round(0.5/0.171)*0.171 = round(2.92)*0.171 = 3*0.171 = 0.513
error = 0.5 - 0.513 = -0.013
H_inv[0, 1:] = [-0.1, 0.0, 0.2], H_inv[0,0] = 0.4
Update: W[1:] -= (-0.013) * [-0.1, 0.0, 0.2] / 0.4
W[1] += (-0.013) * (-0.1) / 0.4 = +0.00325 → 1.203
W[2] += (-0.013) * (0.0) / 0.4 = 0 → -0.300
W[3] += (-0.013) * (0.2) / 0.4 = -0.0065 → 0.7935
Step j=1:
w[1] = 1.203 (already adjusted!)
w_q[1] = round(1.203/0.171)*0.171 = 7*0.171 = 1.197 (now 1.197 ≈ 1.203)
error = 1.203 - 1.197 = 0.006 ← much smaller error after adjustment!
... (continue)
The key: after compensating, the next column's quantization error is smaller — errors are propagated forward and partially cancelled.
4.5 Why Cholesky?
Direct matrix inversion is numerically unstable and $O(n^3)$ every time. Cholesky gives:
- Numerical stability: Cholesky only works for positive-definite matrices (which $H$ is, after damping), and the triangular solve is well-conditioned
- Efficiency: compute $H = LL^T$ once ($O(n^3)$), then triangular solves are $O(n^2)$
- Block processing: GPTQ processes weights in blocks of 128 columns, recomputing a local Cholesky for each block — this reduces numerical drift
# Why this works:
# H @ H_inv = I
# cholesky(H) = L (lower triangular, L @ L.T = H)
# cholesky_solve(I, L) solves: H @ X = I → X = H^{-1}
#
# This is more stable than: H_inv = torch.linalg.inv(H)
# because inv() suffers from floating-point catastrophic cancellation
# for near-singular H
L = torch.linalg.cholesky(H)
H_inv = torch.cholesky_solve(torch.eye(n), L)
4.6 GPTQ vs AWQ vs Naive Quantization: Benchmark
For Llama-2-7B at INT4, on WikiText-2 perplexity:
| Method | PPL (lower = better) | VRAM | Speed |
|---|---|---|---|
| FP16 baseline | 5.47 | 14 GB | 1× |
| Naive INT4 | ~30+ | 3.5 GB | 2.5× |
| GPTQ INT4 | 5.85 | 3.5 GB | 2.5× |
| AWQ INT4 | 5.78 | 3.5 GB | 2.5× |
| QAT INT4 | 5.65 | 3.5 GB | 2.5× |
GPTQ's advantage: purely post-training, needs only ~128 calibration samples, takes ~4 hours for 7B model on a single A100. No gradient computation needed.
4.7 Full Pipeline: Calibration + GPTQ + Deployment
import torch, copy
from transformers import AutoModelForCausalLM, AutoTokenizer
# 1. Load model
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
# 2. Collect calibration data
def get_calibration_data(tokenizer, n_samples=128, seq_len=512):
"""Use a diverse sample of real text for calibration."""
# In practice: C4, WikiText, or domain-specific data
texts = ["The quick brown fox..."] * n_samples
inputs = tokenizer(texts, return_tensors="pt", max_length=seq_len,
truncation=True, padding=True)
return inputs["input_ids"]
cal_data = get_calibration_data(tokenizer)
# 3. Run GPTQ layer-by-layer
def quantize_model_gptq(model, cal_data, bits=4):
"""Collect inputs via hooks, then quantize each layer."""
quantized_model = copy.deepcopy(model)
for name, module in quantized_model.named_modules():
if isinstance(module, nn.Linear):
# Register hook to capture inputs during calibration pass
inputs_collected = []
hook = module.register_forward_hook(
lambda m, inp, out: inputs_collected.append(inp[0].detach()))
# Run calibration
with torch.no_grad():
model(cal_data)
hook.remove()
# Stack calibration inputs and compute Hessian
X_cal = torch.cat([x.reshape(-1, x.shape[-1]) for x in inputs_collected])
H = compute_hessian(X_cal)
# Quantize this layer's weights
W_q = gptq_quantize_layer(module.weight.data, H, bits=bits)
module.weight.data = W_q
return quantized_model
quantized_model = quantize_model_gptq(model, cal_data, bits=4)
# 4. Evaluate
def evaluate_perplexity(model, tokenizer, text):
inputs = tokenizer(text, return_tensors="pt")
with torch.no_grad():
loss = model(**inputs, labels=inputs["input_ids"]).loss
return torch.exp(loss).item()
4.8 Interview Pitfalls
Q: "Explain STE without jargon to a PM."
"We train the model as if the weights were quantized to 8-bit integers — every forward pass uses the rounded values so the model learns to deal with it. But for the backward pass (updating weights), we pretend the quantization step wasn't there and use the normal gradient as if the weights were still floating-point. This trick — called Straight-Through Estimator — lets us train the model to be robust to quantization noise even though we can't actually differentiate through the rounding operation."
Q: "Why does GPTQ need calibration data? Can't you just quantize without it?"
"GPTQ uses the Hessian $H = 2XX^T$ to determine which weights are sensitive to quantization (large $H_{ii}$ = that input dimension is highly active, so quantizing that column's weights hurts more). Without calibration data, we don't know which directions in weight space are important. With 128 random samples, we get a good approximation of the typical input distribution. Without it, we'd have to treat all weights equally — that's naive round-to-nearest, which loses 5–15% accuracy at INT4."
Q: "QAT vs GPTQ — when would you choose each at Qualcomm?"
"For a new on-device model being trained from scratch or fine-tuned: QAT, because you have training data and compute budget, and it gives the best accuracy. For quantizing a large pretrained model (Llama-3, Gemma-2) for NPU deployment without access to the training data or compute: GPTQ (or AWQ), because you only need 128 calibration samples and a few GPU-hours. GPTQ is the practical choice for the productionization of public models."
Q: "What is the relationship between PTQ, QAT, and GPTQ?"
"PTQ: quantize post-hoc with no training. Cheap, works for INT8, fails at INT4.
QAT: simulate quantization during training with STE. Best accuracy, expensive.
GPTQ: second-order PTQ — use Hessian to propagate quantization errors across columns, compensating for each quantized column before moving to the next. Near-QAT accuracy, no training needed."
Section 5: Quick Setup
pip install torch>=2.1.0
# For running GPTQ on real LLMs:
pip install auto-gptq transformers
QAT quick test:
import torch, torch.nn as nn
from phase_03_fundamentals.lab_02_qat.solution import FakeQuantize, wrap_model_for_qat
model = nn.Sequential(nn.Linear(64, 128), nn.ReLU(), nn.Linear(128, 16))
# Check STE works
fq = FakeQuantize(bits=8)
x = torch.randn(4, 64, requires_grad=True)
y = fq(x)
y.sum().backward()
print(f"STE gradient norm: {x.grad.norm():.4f}") # Should be non-zero!
# QAT training
qat_model = wrap_model_for_qat(model, bits=8)
print("QAT layers:", [name for name, m in qat_model.named_modules() if 'QAT' in type(m).__name__])
GPTQ quick test:
from phase_03_fundamentals.lab_03_gptq.solution import compute_hessian, gptq_quantize_layer, compare_reconstruction_error
d_out, d_in, N = 32, 64, 128
X = torch.randn(N, d_in) # calibration inputs
W = torch.randn(d_out, d_in) # pretrained weight
H = compute_hessian(X)
result = compare_reconstruction_error(W, X, bits=4)
print(f"GPTQ error: {result['gptq_error']:.4f}")
print(f"Naive error: {result['naive_error']:.4f}")
print(f"Improvement: {result['improvement_ratio']:.1f}x") # GPTQ should be better
Lab 01 — PTQ Pipeline
Phase: 03 — Quantization & Model Compression | Difficulty: ⭐⭐⭐⭐☆ | Time: 2–3 hours
Post-Training Quantization (PTQ) converts a trained FP32 model to INT8/INT4 without retraining.
What you build
- Symmetric and asymmetric per-tensor/per-channel quantization
- Calibration-based scale/zero-point computation
- Full PTQ pipeline for a linear model
- Accuracy evaluation: cosine similarity and relative error
Key concepts
| Concept | What to understand |
|---|---|
| Scale & zero-point | Mapping float range to integer range |
| Per-channel quant | Each output channel has own scale — better for weight quantization |
| Calibration data | Representative inputs to measure activation ranges |
| Accuracy-vs-compression tradeoff | INT4 = 8× compression, INT8 = 4× compression |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Lab 02 — Quantization-Aware Training (QAT)
Phase: 03 — Quantization & Model Compression | Difficulty: ⭐⭐⭐⭐☆ | Time: 3–4 hours
QAT trains with simulated quantization noise — the model learns to be robust to INT8/INT4 precision.
What you build
FakeQuantize— asymmetric per-tensor fake-quant with Straight-Through Estimator (STE)QATLinear— Linear layer with fake-quantized weightswrap_model_for_qat— replace all Linear layers with QATLinear (deepcopy safe)train_qat— AdamW training loop on QAT modelconvert_to_int8— fold scale/zero_point into INT8 weights post-training
Key concepts
| Concept | What to understand |
|---|---|
| STE | x_out = x + (quant(x) - x).detach() — gradients flow through |
| FakeQuantize | Quantize + dequantize in forward; model learns to minimize error |
| INT8 conversion | After QAT, actual INT8 weights match dequantized FakeQuant output |
| Accuracy vs PTQ | QAT typically +1–3% accuracy over PTQ at same bit width |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Lab 03 — GPTQ: Optimal Brain Quantization
Phase: 03 — Quantization & Model Compression | Difficulty: ⭐⭐⭐⭐⭐ | Time: 4–5 hours
GPTQ (Frantar et al., 2022) quantizes LLMs to INT4 with minimal accuracy loss using second-order weight optimization.
What you build
compute_hessian— H = 2·X·X^T + damping·I from calibration activationssymmetric_quantize— INT4/INT8 symmetric per-tensor quantizationgptq_quantize_layer— column-wise OBQ with Cholesky-based Hessian inverse updatecompare_reconstruction_error— GPTQ vs naive round-to-nearestGPTQLayer— inference layer with W_q as buffer (no grad)gptq_quantize_model— full pipeline with forward hooks for calibration
Key concepts
| Concept | What to understand |
|---|---|
| OBQ | Optimal Brain Quantizer: minimize per-column reconstruction error |
| Hessian | H = 2XX^T captures sensitivity of loss to weight perturbations |
| Cholesky update | Numerically stable Hessian inverse for sequential column updates |
| Error propagation | Quantizing column j updates remaining columns to compensate |
| GPTQ vs AWQ | GPTQ: Hessian-based; AWQ: activation scaling — both INT4 SOTA |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Phase 04 — torch.compile & FX Graph Optimization
Difficulty: ⭐⭐⭐⭐⭐
Estimated Time: 3 weeks (70–90 hours)
Roles Supported: Framework engineers, NPU backend engineers — understanding torch.compile is mandatory for writing ops that work at production scale
Why This Phase Exists
torch.compile is the future of PyTorch performance. Since PyTorch 2.0, every major model deployment at Google, Meta, and NVIDIA goes through some form of graph compilation. At Qualcomm, the ML framework team's job is to make their NPU backend a first-class torch.compile target.
This requires deep understanding of three systems: TorchDynamo (how Python bytecode becomes an FX graph), AOTAutograd (how the joint forward+backward graph is constructed), and TorchInductor (how FX graphs are lowered to CUDA/C++/Triton kernels). If you can write a custom torch.compile backend that correctly handles 90% of LLaMA's operations, you are the kind of engineer Qualcomm is hiring.
Concepts
- TorchDynamo: Python bytecode analyzer; installs a frame evaluation callback; traces tensor-producing operations into an FX graph; inserts guards for assumptions made during tracing
- Graph breaks: when TorchDynamo encounters unsupported Python constructs (dynamic control flow on tensor values, non-PyTorch library calls), it splits the computation into multiple FX graphs separated by Python execution
- FX Graph IR: a DAG of
Nodeobjects with ops:placeholder,call_function,call_module,call_method,output; serializable, manipulable, inspectable - AOTAutograd: ahead-of-time autograd — computes the joint forward+backward graph before execution; enables compiling the backward pass alongside the forward
- TorchInductor: default backend; generates Triton kernels (GPU) or C++/OpenMP (CPU); performs operator fusion, memory layout optimization, loop tiling
- FX graph transformations:
Interpreter(rerun with custom op dispatch),Transformer(node replacement), pattern matching viatorch.fx.subgraph_rewriter - Operator fusion: combining multiple ops into a single kernel (e.g., conv+BN, LayerNorm+dropout, matmul+bias+ReLU) reduces memory bandwidth pressure
- Dead code elimination (DCE): remove graph nodes whose output is never used
- Constant folding: evaluate subgraphs with only constant inputs at compile time
- Shape specialization vs dynamic shapes:
torch.compilespecializes on input shapes by default (fast) or can usedynamic=Truefor shape-agnostic compilation (slower, more general) - Custom backends: any callable
(fx.GraphModule, example_inputs) → callablecan be registered as atorch.compilebackend - Triton: Python-embedded GPU kernel language; used by TorchInductor; enables writing performance-critical kernels without CUDA C++
Labs
Lab 01 — FX Graph Transformation Passes
| Field | Value |
|---|---|
| Goal | Write 4 FX graph transformation passes: (1) Conv2d + BatchNorm fusion, (2) constant folding, (3) dead node elimination, (4) Op replacement (swap relu for hardswish for NPU compatibility) |
| Concepts | torch.fx.symbolic_trace, torch.fx.Interpreter, torch.fx.Transformer, node pattern matching, torch.nn.utils.fusion.fuse_conv_bn_eval, GraphModule.recompile() |
| Steps | 1. Implement ConvBNFuser(fx.Transformer) that fuses sequential Conv2d+BN; 2. Implement ConstantFolder(fx.Interpreter) that evaluates constant subgraphs; 3. Implement DCEPass that removes nodes with zero users; 4. Implement OpReplacer for configurable op substitution; 5. Compose passes into a PassManager with before/after accuracy validation; 6. Benchmark ResNet-50: passes applied vs baseline (latency + accuracy) |
| Stack | PyTorch 2.3+, torchvision |
| Datasets | ImageNet val subset (1000 images) |
| Output | PassManager with all 4 passes; ResNet-50 benchmark: latency reduction with <0.1% accuracy drop; pass unit tests |
| How to Test | pytest test_lab.py — checks: (1) Conv+BN fusion reduces node count, (2) fused model accuracy matches original within 1e-5, (3) constant subgraphs are correctly evaluated, (4) dead nodes are eliminated |
| Talking Points | What is the mathematical equivalence behind conv+BN fusion? Why can constant folding improve latency? What makes DCE safe (when might DCE be incorrect)? |
| Resume Bullet | Wrote 4 FX graph optimization passes (Conv-BN fusion, constant folding, DCE, op replacement); demonstrated 12% latency improvement on ResNet-50 with <0.1% accuracy delta |
| Extensions | Add quantization-aware fusion (conv+bn+relu → quantized fused op); add symbolic shape analysis; implement algebraic simplification (x + 0 = x) |
Lab 02 — Custom torch.compile Backend
| Field | Value |
|---|---|
| Goal | Write a custom torch.compile backend (npu_simulator) that lowers an FX graph to a mock NPU instruction set (a simple JSON-based IR), with fallback to PyTorch CPU for unsupported ops |
| Concepts | @register_backend, fx.GraphModule, example_inputs, lowering, fallback partitioning, torch.fx.passes.operator_support, BackendPatternBase |
| Steps | 1. Define 15-op NPU ISA in JSON (matmul, add, relu, softmax, layernorm, etc.); 2. Implement FX→NPU IR lowering (supported ops → NPU IR nodes, unsupported → CPU fallback); 3. Implement NpuSimulator that executes the JSON IR; 4. Register backend via @register_backend; 5. Test with torch.compile(model, backend="npu_simulator"); 6. Measure partition quality: % ops handled by NPU vs CPU fallback on LLaMA-tiny |
| Stack | PyTorch 2.3+, torch._dynamo, torch.fx |
| Datasets | Synthetic (random inputs) |
| Output | Registered npu_simulator backend; LLaMA-tiny running under torch.compile(backend="npu_simulator") with >85% ops handled by NPU; op coverage report |
| How to Test | pytest test_lab.py — checks: backend registered correctly, output matches torch.compile(backend="eager") within 1e-4, partition report generated, fallback rate < 15% for standard models |
| Talking Points | How does torch.compile determine which ops go to which backend? What is operator_support.OperatorSupport? When should you prefer a graph break over a fallback? |
| Resume Bullet | Wrote custom torch.compile backend targeting mock NPU ISA; achieved 87% op coverage for LLaMA-style models with automatic CPU fallback for unsupported ops |
| Extensions | Add profiling mode (timing each NPU op); implement operator decomposition (break unsupported ops into supported primitives); add memory layout optimization (NHWC vs NCHW) |
Lab 03 — Triton Fused Kernel + Inductor Lowering
| Field | Value |
|---|---|
| Goal | Write a fused LayerNorm+GeLU Triton kernel and register it as a TorchInductor lowering so that torch.compile automatically uses your kernel whenever it encounters this pattern |
| Concepts | Triton programming model, blocked tile computation, SRAM vs DRAM, Inductor lowering via @torch._inductor.lowering.register_lowering, FX pattern matching, performance benchmarking |
| Steps | 1. Implement layernorm_gelu_kernel in Triton: fused computation that reads x once, computes LayerNorm, applies GeLU, writes output (avoids two DRAM passes); 2. Verify correctness vs F.layer_norm + F.gelu within 1e-4; 3. Benchmark vs sequential and vs torch.compile default; 4. Register as Inductor lowering; 5. Verify torch.compile(model) now uses your kernel automatically via Triton's @triton.jit |
| Stack | Triton 2.3+, PyTorch 2.3+, CUDA GPU required |
| Datasets | Synthetic: torch.randn(batch, seq, d_model) |
| Output | layernorm_gelu_kernel achieving ~1.4× speedup over sequential on batch=32,seq=2048,d=4096; registered as Inductor lowering with correctness verification |
| How to Test | pytest test_lab.py — checks: Triton output matches PyTorch within 1e-4, kernel handles edge cases (d_model not divisible by BLOCK_SIZE), perf > 1.2x on target shape, Inductor lowering fires correctly |
| Talking Points | How does Triton's blocked programming model map to GPU warp/block hierarchy? What determines BLOCK_SIZE choice? What is memory coalescing and why does your kernel achieve it? |
| Resume Bullet | Wrote fused LayerNorm+GeLU Triton kernel registered as TorchInductor lowering; achieved 1.43× speedup over sequential ops at transformer-scale dimensions (seq=2048, d=4096) |
| Extensions | Add FP8 input support; implement fused MHA (Q,K,V projection + attention in one kernel); contribute to PyTorch's Triton op library |
Deliverables Checklist
- 4 FX graph transformation passes with benchmark results
-
Custom
torch.compilebackend with op coverage report - Triton fused kernel registered as Inductor lowering
-
All
test_lab.pysuites pass
Interview Relevance
- "Walk me through what happens when I call
torch.compile(model)(x)." — you can describe the full stack from Python bytecode → FX → AOTAutograd → Inductor → CUDA/Triton - "How would you write a
torch.compilebackend for the Qualcomm HTP?" — Lab 02 gives you the complete template - "What is a graph break and why does it hurt performance?" — you've encountered them in Lab 02 during op coverage analysis
- "Write a Triton kernel for softmax." — Lab 03 gives you the mental model for tiling and warp-level reduction
Warmup Guide — torch.compile, FX Graph & Custom Backends
Zero-to-expert primer for Phase 04. Builds the compilation stack from "why graphs at all" through TorchDynamo's bytecode capture, FX IR, guards, backends, Inductor, and Triton — assuming Phase 01's dispatcher knowledge.
Table of Contents
- Chapter 1: Eager vs Graph — Why Capture Exists
- Chapter 2: torch.fx — The IR You Will Actually Touch
- Chapter 3: Graph Passes — Reading, Matching, Rewriting
- Chapter 4: TorchDynamo — Capture Without Tracing's Lies
- Chapter 5: Guards and Graph Breaks
- Chapter 6: AOTAutograd and Decompositions
- Chapter 7: Backends — Where Your Code Plugs In
- Chapter 8: TorchInductor and Triton
- Chapter 9: Kernel Fusion — The Point of All This
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Eager vs Graph — Why Capture Exists
Eager mode (default PyTorch): each Python line dispatches one kernel immediately. Maximum flexibility — arbitrary control flow, prints, debugger — but the GPU sees one op at a time. Costs: per-op Python/dispatcher overhead (microseconds each, fatal for small ops), and no cross-op optimization — every op writes its result to DRAM and the next op reads it back.
Graph mode: first capture the computation as a data structure, then optimize it as a whole (fuse ops, plan memory, specialize on shapes), then execute the optimized artifact. The entire phase is about the capture-optimize-execute pipeline and where you, the NPU/compiler engineer, insert your own stages.
The capture problem: Python is too dynamic to compile directly. Three historical strategies, all of which you should be able to compare:
- Trace (
torch.jit.trace,fx.symbolic_trace): run the function with example inputs, record ops. Fast, but control flow is baked in — the trace lies if a branch depends on data. - Script (TorchScript): parse Python source into a static language subset. Honest about control flow, but the subset is painful and the project is effectively frozen.
- Bytecode interception (TorchDynamo, Chapter 4): hook CPython frame evaluation, symbolically execute bytecode, and fall back to Python wherever capture fails. This is the approach that finally worked.
Chapter 2: torch.fx — The IR You Will Actually Touch
FX is PyTorch's Python-level intermediate representation: a GraphModule wrapping a
Graph of Nodes. Each node has:
op: one ofplaceholder(input),get_attr(parameter fetch),call_function(torch.add,operator.mul...),call_method(x.view),call_module(self.linear),output.target: the thing called (function object, attr name, module path).args/kwargs: inputs, which may be otherNodes — this linking is the dataflow graph.meta: a dict where shape/dtype propagation and your own passes stash information (node.meta["tensor_meta"]afterShapeProp).
How symbolic tracing works: call the module with Proxy objects instead of tensors;
every operation on a Proxy records a node and returns a new Proxy. This is also exactly
why it fails on data-dependent control flow — if proxy > 0: needs a concrete bool that
doesn't exist. (Dynamo solves this differently; FX remains the IR.)
Why FX matters to you: it is the interchange format. Dynamo produces FX graphs;
quantization workflows annotate FX graphs; your Lab 02 NPU backend consumes an FX graph.
It's Python objects all the way down — printable (graph.print_tabular()), mutable, and
testable.
Chapter 3: Graph Passes — Reading, Matching, Rewriting
A pass is a function GraphModule → GraphModule. The three core skills (all in Lab 01):
1. Analysis: walk graph.nodes (topologically ordered), classify, count, propagate
shapes (torch.fx.passes.shape_prop.ShapeProp executes the graph with FakeTensors to
fill meta).
2. Pattern matching: find subgraphs like conv → batch_norm. The robust way is
structural: for each batch_norm node, check node.args[0] is a conv with exactly one
user (you!). Single-user checks matter — if the conv output feeds elsewhere, fusing it
away is wrong. This single-user discipline is where most homegrown passes have bugs.
3. Rewriting: create the replacement (e.g., fold BN's $\gamma/\sqrt{\sigma^2+\epsilon}$
scale into conv weight, $\beta - \mu\gamma/\sqrt{\sigma^2+\epsilon}$ into bias), insert
with graph.inserting_after(node), reroute consumers via node.replace_all_uses_with(new),
graph.erase_node the dead ones, then graph.lint() and gm.recompile().
Conv-BN folding math (Lab 01's centerpiece): BN at inference is an affine transform per channel; an affine transform of a conv output is absorbable into the conv's weights and bias:
$$W' = \frac{\gamma}{\sqrt{\sigma^2 + \epsilon}} \cdot W, \qquad b' = \frac{\gamma,(b - \mu)}{\sqrt{\sigma^2+\epsilon}} + \beta$$
One kernel instead of two, and — critical for Phase 03 — quantization observers see the folded distribution, which is the one that ships.
Chapter 4: TorchDynamo — Capture Without Tracing's Lies
Mechanism: PEP 523 lets a C extension replace CPython's frame evaluation. When a
torch.compile'd function runs, Dynamo intercepts the frame before execution and
symbolically executes its bytecode: tensor operations append FX nodes; Python-level
operations (list appends, attribute reads) are tracked symbolically; anything
un-traceable triggers a graph break (Chapter 5).
The output per frame: (1) an FX graph of the tensor compute, (2) a guard set — runtime predicates under which this graph is valid, (3) rewritten bytecode that calls the compiled graph and resumes Python where capture stopped.
Why bytecode, not source or tracing: bytecode is what actually executes (no parser divergence); symbolic execution sees real Python semantics (closures, globals, builtins); and fallback is natural — any unsupported construct just ends the graph and lets CPython continue. Tracing can't fall back; scripting can't handle full Python. Dynamo's bet: capture most of the program honestly, rather than all of it wrongly.
Chapter 5: Guards and Graph Breaks
Guards make specialization safe. The captured graph assumed things: x.shape == (8, 512), x.dtype == float16, self.training == False, that global flag's value.
Each assumption becomes a guard — a cheap predicate checked on every subsequent call.
All pass → run the compiled artifact. Any fail → recompile for the new situation
(each function holds a cache of (guards → compiled graph) entries; exceeding
torch._dynamo.config.cache_size_limit falls back to eager, your classic silent perf
bug).
Dynamic shapes: by default Dynamo specializes on exact shapes; on the second shape it
sees, it generalizes that dimension to symbolic (s0) with SymInt arithmetic — guards
become constraints like s0 > 1 rather than s0 == 8. dynamic=True requests this
up front.
Graph breaks happen at: data-dependent control flow on tensor values (if x.sum() > 0), .item()/.tolist() (forces materialization), unsupported builtins/C extensions,
prints. The cost is not correctness — it's fragmentation: each fragment compiles
separately, killing cross-fragment fusion, with eager Python between. Diagnose with
torch._dynamo.explain(fn)(args) or TORCH_LOGS=graph_breaks; the fix is usually
rewriting the offending line in tensor terms (torch.where instead of if).
Chapter 6: AOTAutograd and Decompositions
The FX graph Dynamo captures is forward only and in terms of high-level ops. AOTAutograd traces through the autograd machinery ahead of time, producing a joint forward+backward graph, then partitions it into separate forward/backward graphs — choosing what to save vs recompute between them (this is where activation recomputation decisions live). Your backend may therefore receive a backward graph too.
Decompositions: PyTorch has ~2000 operators; no backend wants to implement them all.
The _decomp registry rewrites composite ops into a small primitive set (prims / core
ATen IR — a few hundred ops). Your Lab 02 backend declares which ops it handles natively
and lets decompositions lower the rest. Functionalization also runs here: in-place
ops (add_) become pure ops plus explicit copies, giving backends a side-effect-free
graph.
Chapter 7: Backends — Where Your Code Plugs In
A backend is just a callable:
def my_backend(gm: torch.fx.GraphModule, example_inputs):
# inspect/compile gm.graph however you like...
return compiled_callable # same signature as gm.forward
torch.compile(model, backend=my_backend). Whatever you return gets called with real
tensors; returning gm.forward unchanged is a valid (null) backend — start there.
The real-world pattern your Lab 02 implements is partitioning: walk the graph, mark nodes your NPU supports, group maximal supported subgraphs, compile those to your ISA, and leave the rest to eager. This is exactly how QNN/TensorRT/CoreML integrations work, and the partition boundaries (with their device-transfer costs) are where deployment performance is won or lost — few large islands beat many small ones, every time.
Chapter 8: TorchInductor and Triton
Inductor is the default backend: FX → its own loop-level IR (define-by-run, scheduling
fusion decisions) → Triton source for GPU (or C++/OpenMP for CPU) → compiled and
cached. You can read everything it generates: TORCH_LOGS=output_code.
Triton is a Python DSL for writing GPU kernels at the block level: you write the
program one block of threads executes (tl.load a tile, compute, tl.store), and the
compiler handles the within-block parallelism, memory coalescing, and software
pipelining that CUDA makes you do by hand. The mental model shift from CUDA: think
"tile program with masks", not "one thread's scalar program". Lab 03 has you write the
fused kernel by hand that Inductor would generate, then benchmark both.
Chapter 9: Kernel Fusion — The Point of All This
Why does any of this matter? Memory traffic. Unfused y = relu(x + b):
- kernel 1 reads $x, b$ (2n), writes $t$ (n)
- kernel 2 reads $t$ (n), writes $y$ (n) → 5n DRAM traffic + 2 launches
Fused: read $x, b$, write $y$ → 3n and 1 launch. For elementwise chains (bias, norm, activation, dropout, residual — i.e., half a transformer layer), fusion is a 1.5–3× end-to-end win because these ops are bandwidth-bound (Phase 07's roofline makes this quantitative: fusion raises arithmetic intensity). Matmuls themselves are usually left to cuBLAS/vendor kernels; the win is fusing the elementwise epilogue into them (Inductor does both).
This is also the NPU story (Phase 06): NPU compilers live and die by how long an op chain they can keep inside on-chip SRAM without a DRAM round-trip.
Lab Walkthrough Guidance
Order: Lab 01 (FX passes) → Lab 02 (backend) → Lab 03 (Triton).
- Lab 01: print the graph first (
print_tabular()); write the analysis pass before the rewrites; for conv-BN folding, verify numerics against the unfused module on random inputs to 1e-6 — then check the single-user edge case (a conv feeding two consumers must not fold). - Lab 02: start with the null backend (return
gm.forward) to see what Dynamo hands you and how often (add a counter — watch recompiles happen as you vary shapes). Then op classification, then partitioning, then the mock-ISA codegen. Keep asupported_opsset as data, not code — that's how real integrations stay maintainable. - Lab 03: get the unfused PyTorch reference and its timing first. Write the Triton kernel with masks for the ragged tail; verify exactness, then benchmark across sizes — small sizes show launch overhead, large show bandwidth; explain both regimes with Chapter 9's math.
Success Criteria
You are ready for Phase 05 when you can, from memory:
- Name the six FX node ops and walk a printed graph aloud.
- Write conv-BN folding's weight/bias formulas and state the single-user precondition.
- Explain how Dynamo captures (bytecode symbolic execution), what a guard is, and the recompile-vs-graph-break distinction.
- List four graph-break triggers and the tensor-level rewrite for each.
- Describe what AOTAutograd and decompositions transform, and why backends want core ATen IR rather than all ~2000 ops.
- Compute the 5n→3n fusion traffic example and generalize it to a longer chain.
Interview Q&A
Q: torch.compile made my model slower. Diagnose.
In order: (1) compile-time amortization — measure steady state, not first calls;
(2) graph breaks fragmenting the model — torch._dynamo.explain, count fragments;
(3) recompile storms from varying shapes/Python values — TORCH_LOGS=recompiles, fix
with dynamic=True or by passing values as tensors; (4) cache-limit fallback to eager;
(5) tiny workload where launch overhead dominated and CUDA graphs (mode= "reduce-overhead") is the actual fix.
Q: Why did Dynamo succeed where TorchScript failed? TorchScript demanded the whole program fit a static subset — one unsupported construct and you rewrite your codebase. Dynamo captures incrementally with graceful fallback: unsupported code just breaks the graph and runs in Python. Adoption cost went from "rewrite" to "decorate", with a perf gradient (fix breaks as you find them) instead of a correctness cliff.
Q: Your NPU supports conv/matmul/elementwise but not softmax. What does your backend do? Partition: maximal supported subgraphs run on NPU; softmax stays on host. Each boundary costs a device round-trip, so fuse aggressively within islands and consider whether a decomposition of softmax (exp/sum/div — all supported elementwise ops!) lets you keep it on-device — that judgment call is exactly Lab 02's design space.
Q: What does a Meta/Fake kernel do and why does compile need it?
It computes output shapes and dtypes without touching data. Dynamo/AOTAutograd trace
with FakeTensors — every op must propagate metadata or capture fails; it's also how shape
specialization and memory planning happen before any real kernel runs. (You wrote one for
warp_softmax in Phase 01 Lab 02.)
References
- torch.fx documentation
- Reed et al., torch.fx: Practical Program Capture and Transformation for DL in Python (2021) — arXiv:2112.08429
- TorchDynamo deep-dive (PyTorch dev forum)
- Ansel et al., PyTorch 2: Faster Machine Learning Through Dynamic Python Bytecode Transformation and Graph Compilation (ASPLOS 2024)
- torch.compile troubleshooting guide
- Tillet et al., Triton: An Intermediate Language and Compiler for Tiled Neural Network Computations (2019)
- Triton tutorials — work through vector-add and fused-softmax
- PEP 523 — Adding a frame evaluation API to CPython
The Hitchhiker's Guide — Phase 04: torch.compile, FX Graph, Custom Backends
"The first time you look at what torch.compile actually does to your model, you will have one of two reactions: either 'this is incredibly clever' or 'this is incredibly frightening.' Both reactions are correct." — anonymous PyTorch contributor
Section 1: torch.compile Architecture — Three Layers
torch.compile is not a single tool. It is a three-layer compiler pipeline:
Python code
↓
[Layer 1] TorchDynamo — bytecode rewriting, Python frame capture
↓
FX Graph (Aten/Prims ops only, no Python control flow)
↓
[Layer 2] AOTAutograd — ahead-of-time differentiation, lifts grad into the graph
↓
Joint forward+backward FX graph
↓
[Layer 3] Backend — inductor (default), nvfuser, custom backend
↓
Optimized code / Triton kernels
Layer 1: TorchDynamo — Bytecode Rewriting
TorchDynamo intercepts Python execution at the CPython bytecode level. It does not use tracing in the classical sense — it evaluates Python code and builds an FX graph from the operations it observes.
The key mechanism is torch._dynamo.eval_frame.set_eval_frame(), which installs a callback that intercepts CPython's frame.f_back evaluation. When a guarded region encounters Python control flow (an if statement, a for loop with dynamic bounds), Dynamo breaks the graph at that point, compiling everything before the break into one subgraph and resuming Python execution.
Graph breaks are the primary performance enemy of torch.compile. Every graph break means:
- A Triton/C++ dispatch overhead
- Loss of fusion opportunities across the break
- Potential synchronization
Common graph break causes:
# 1. data-dependent control flow
if x.sum() > 0: # .sum() returns a Python scalar → break
...
# 2. non-PyTorch library calls
import numpy as np
y = np.array(x.numpy()) # leaves PyTorch graph → break
# 3. custom __torch_function__ with Python fallback
class MyTensor(torch.Tensor):
def __add__(self, other):
return super().__add__(other) # may break depending on implementation
# 4. print statements
print(x.shape) # accesses tensor metadata but causes Python escape → break
# 5. in-place ops on tensors that require grad
x += 1 # may cause aliasing issues → break
How to debug graph breaks:
import torch._dynamo
torch._dynamo.config.verbose = True
# Or use explain():
explanation = torch._dynamo.explain(model)(x)
print(explanation.graphs) # compiled subgraphs
print(explanation.break_reasons) # why each break happened
Guards: for each compiled subgraph, Dynamo generates guard conditions — Python predicates that must be true for the compiled version to be valid. If guards fail (e.g., input shape changed), Dynamo recompiles (within the cache_size_limit).
Example guard for a linear layer:
# Dynamo generates something like:
def guard_fn(input):
return (
input.dtype == torch.float32 and
input.device.type == 'cuda' and
input.shape[1] == 512 # static shape guard
)
Dynamic shapes (torch.compile(dynamic=True)) replace static shape guards with symbolic shape guards using torch.fx.experimental.symbolic_shapes.
Section 2: FX — Functional Transformation of PyTorch Graphs
torch.fx is the intermediate representation used throughout torch.compile. It represents a PyTorch program as a directed acyclic graph of nodes.
FX Node Types
| NodeKind | Example | Description |
|---|---|---|
placeholder | x: f32[B,512] | Function argument |
get_attr | self.weight | Module parameter access |
call_function | torch.relu(x) | Free function call |
call_method | x.sum() | Tensor method call |
call_module | self.linear(x) | nn.Module forward call |
output | return y | Function output |
Capturing an FX Graph
import torch
import torch.fx as fx
model = torch.nn.Sequential(
torch.nn.Linear(512, 256),
torch.nn.ReLU(),
torch.nn.Linear(256, 10)
)
# Symbolic tracing (static, follows Python code)
tracer = fx.symbolic_trace(model)
print(tracer.graph)
# torch.export (newer, handles more cases, produces aten-level graph)
from torch.export import export
exported = export(model, args=(torch.randn(1, 512),))
print(exported.graph)
Writing FX Passes
An FX pass is a function that takes a GraphModule, transforms its graph, and returns the modified GraphModule.
Pattern: Replace all ReLU with LeakyReLU
import torch.fx as fx
def replace_relu_with_leaky(gm: fx.GraphModule) -> fx.GraphModule:
for node in gm.graph.nodes:
if node.op == 'call_function' and node.target == torch.nn.functional.relu:
with gm.graph.inserting_after(node):
new_node = gm.graph.call_function(
torch.nn.functional.leaky_relu,
args=node.args,
kwargs={'negative_slope': 0.01}
)
node.replace_all_uses_with(new_node)
gm.graph.erase_node(node)
gm.recompile()
return gm
Pattern: Fuse Linear + ReLU → LinearReLU
def fuse_linear_relu(gm: fx.GraphModule) -> fx.GraphModule:
graph = gm.graph
for node in list(graph.nodes):
# Match: relu(linear(x))
if (node.op == 'call_module' and
isinstance(gm.get_submodule(node.target), torch.nn.ReLU)):
linear_node = node.args[0]
if (linear_node.op == 'call_module' and
isinstance(gm.get_submodule(linear_node.target), torch.nn.Linear)):
# Create fused module
linear = gm.get_submodule(linear_node.target)
fused = torch.nn.utils.fusion.fuse_linear_bn_eval(linear, None)
# ... (register fused module, replace both nodes with one)
gm.recompile()
return gm
Pattern: Constant folding
def constant_fold(gm: fx.GraphModule) -> fx.GraphModule:
"""Evaluate ops with constant inputs at compile time."""
graph = gm.graph
for node in list(graph.nodes):
if node.op == 'call_function':
# Check if all inputs are constants (graph inputs are not)
if all(isinstance(arg, (int, float, bool)) or
(isinstance(arg, fx.Node) and arg.op == 'get_attr')
for arg in node.args):
try:
# Execute the op with concrete values
const_value = node.target(*[
gm.get_parameter(a.target) if isinstance(a, fx.Node) else a
for a in node.args
])
# Replace node with constant
with graph.inserting_before(node):
const_node = graph.get_attr(f'_const_{node.name}')
gm.register_buffer(f'_const_{node.name}', const_value)
node.replace_all_uses_with(const_node)
graph.erase_node(node)
except Exception:
pass # can't fold — skip
gm.recompile()
return gm
Section 3: Registering a Custom torch.compile Backend
A custom backend receives an FX GraphModule and returns a callable (typically a Python function wrapping compiled code).
from torch._dynamo.backends.common import aot_autograd
import torch._dynamo as dynamo
def my_custom_backend(gm: torch.fx.GraphModule, example_inputs):
"""
gm: FX GraphModule (after AOTAutograd, contains aten-level ops)
example_inputs: list of example input tensors (for shape/dtype info)
Must return: callable with same signature as gm.forward
"""
print(f"Custom backend received graph with {len(list(gm.graph.nodes))} nodes")
# Option 1: Apply transformations and return gm itself
gm = my_optimization_pass(gm)
return gm
# Option 2: JIT compile the graph (e.g., via TorchScript)
scripted = torch.jit.script(gm)
return scripted
# Option 3: Lower to custom ISA and return simulation
npu_program = lower_to_npu(gm, example_inputs)
return npu_program.simulate # callable that simulates execution
# Register the backend
@dynamo.register_backend
def my_backend(gm, example_inputs):
return my_custom_backend(gm, example_inputs)
# Use it:
model = MyModel()
compiled = torch.compile(model, backend="my_backend")
output = compiled(x)
Using AOT Autograd with a custom backend:
from torch._dynamo.backends.common import aot_autograd
def my_inference_backend(gm, example_inputs):
# Receives joint graph (fwd+bwd combined)
# Lower to hardware
return lower_to_hardware(gm)
my_backend_with_aot = aot_autograd(fw_compiler=my_inference_backend)
compiled = torch.compile(model, backend=my_backend_with_aot)
Section 4: Triton Kernels — When and How
Triton is a Python-based GPU kernel language that compiles to PTX. It is the backend for TorchInductor's generated kernels.
Key concepts:
- Programs: Triton launches many programs (like CUDA blocks); each program handles a tile of data
tl.program_id(axis): which tile this program handlestl.load/tl.store: vectorized memory ops with optional maskingtl.dot: matrix multiply of tiles (uses tensor cores)tl.constexpr: compile-time constant (like CUDAconstexprtemplate param)
Softmax kernel pattern:
import triton
import triton.language as tl
@triton.jit
def softmax_kernel(
input_ptr, output_ptr,
n_rows, n_cols,
BLOCK_SIZE: tl.constexpr # must be power of 2, ≥ n_cols
):
row_idx = tl.program_id(0)
row_start = input_ptr + row_idx * n_cols
cols = tl.arange(0, BLOCK_SIZE)
mask = cols < n_cols
# Load row (with masking for out-of-bounds)
row = tl.load(row_start + cols, mask=mask, other=float('-inf'))
# Numerically stable softmax
row_max = tl.max(row, axis=0)
row -= row_max # broadcast
row_exp = tl.exp(row)
row_sum = tl.sum(row_exp, axis=0)
softmax_output = row_exp / row_sum
# Store result
output_start = output_ptr + row_idx * n_cols
tl.store(output_start + cols, softmax_output, mask=mask)
Autotuning:
@triton.autotune(
configs=[
triton.Config({'BLOCK_SIZE': 128}),
triton.Config({'BLOCK_SIZE': 256}),
triton.Config({'BLOCK_SIZE': 512}),
triton.Config({'BLOCK_SIZE': 1024}),
],
key=['n_cols'] # re-tune when n_cols changes
)
@triton.jit
def softmax_kernel(..., BLOCK_SIZE: tl.constexpr):
...
Integrating Triton kernel as a custom torch.op:
class TritonSoftmax(torch.autograd.Function):
@staticmethod
def forward(ctx, x):
n_rows, n_cols = x.shape
output = torch.empty_like(x)
BLOCK_SIZE = triton.next_power_of_2(n_cols)
softmax_kernel[(n_rows,)](
x, output, n_rows, n_cols, BLOCK_SIZE=BLOCK_SIZE
)
ctx.save_for_backward(output)
return output
@staticmethod
def backward(ctx, grad_output):
output, = ctx.saved_tensors
# Softmax backward: grad_in = output * (grad_out - (grad_out * output).sum(dim=-1, keepdim=True))
...
triton_softmax = TritonSoftmax.apply
Section 5: FX Graph Optimization Passes — Reference List
These are the passes TorchInductor applies (and you can apply in a custom backend):
| Pass | What It Does | When Useful |
|---|---|---|
| Dead Code Elimination (DCE) | Remove nodes whose outputs are never used | After any graph modification |
| Common Subexpression Elimination (CSE) | Share identical computations | Repeated ops in transformers |
| Constant Folding | Evaluate constant subgraphs at compile time | Bias initialization, shape computations |
| Operator Fusion | Combine adjacent ops into one kernel | Memory-bound op sequences |
| Layout Propagation | Propagate preferred memory layouts (NHWC vs NCHW) | Conv-heavy models |
| Quantization Insertion | Insert Q/DQ nodes around quantizable ops | INT8/INT4 deployment |
| Shape Propagation | Infer output shapes for all nodes | Enables downstream static allocation |
| Alias Analysis | Track tensor aliasing for in-place op safety | Before in-place mutations |
Section 6: Interview Pitfalls
| Question | Wrong Answer | Right Answer |
|---|---|---|
| "What does torch.compile do?" | "It makes PyTorch faster" | "Three-layer pipeline: TorchDynamo (bytecode capture) → AOTAutograd (lift grads) → Inductor/custom backend (code generation)" |
| "What is a graph break?" | "When the model doesn't compile" | "A point where Dynamo cannot trace ahead — due to data-dependent control flow, non-PyTorch ops, or unsupported Python constructs. Breaks the program into compiled subgraphs with Python glue." |
| "How do you write an FX pass?" | "You subclass nn.Module" | "You iterate over gm.graph.nodes, match patterns, use inserting_before/after, replace_all_uses_with, erase_node, then recompile()." |
| "How does torch.compile handle control flow?" | "It traces both branches" | "Dynamo breaks the graph at control flow and falls back to Python. For dynamic control flow, you can use torch.cond (aten-level if) or accept the graph break." |
"What is the difference between symbolic_trace and torch.export?" | (unsure) | "symbolic_trace does Python-level tracing (can miss dynamic behaviors); torch.export uses TorchDynamo under the hood, produces a strict aten-level graph that is guaranteed to have no Python dependencies — suitable for mobile/server export" |
Section 7: Resources
- PyTorch torch.compile tutorial — https://pytorch.org/tutorials/intermediate/torch_compile_tutorial.html
- TorchDynamo overview — Jason Ansel et al., "TorchDynamo: Towards Principled Python JIT Compilation" (Meta AI, 2022)
- TorchInductor — blog post on the default backend architecture: https://dev-discuss.pytorch.org/t/torchinductor-a-pytorch-native-compiler-with-define-by-run-ir-and-symbolic-shapes
- Triton paper — Philippe Tillet et al., "Triton: An Intermediate Language and Compiler for Tiled Neural Network Computations" (MLSys 2019)
- FX documentation — https://pytorch.org/docs/stable/fx.html
- torch.export documentation — https://pytorch.org/docs/stable/export.html
- AOTAutograd internals — https://github.com/pytorch/pytorch/blob/main/torch/_functorch/aot_autograd.py (read the docstring)
👨🏻 Brother Talk — Phase 04, Off the Record
torch.compile & FX — the JD names this explicitly ("model level optimization using techniques like torch compile"). This is the bridge from eager PyTorch to the compiler world.
Brother, the JD calls out torch.compile by name — that's rare and it tells you this is a
must-have, not a nice-to-have. And here's why it matters for Qualcomm specifically: a custom
hardware backend (like Qualcomm's QNN) hooks into PyTorch through exactly this machinery. When
you write a torch.compile backend, you're doing the same thing the QNN integration does —
capture the graph, transform it for your hardware, hand back something runnable. So this phase
isn't "learn a speedup flag." It's "learn how to put your hardware into PyTorch." That's the
job.
Let me demystify the stack, because the names intimidate people. torch.compile is three pieces:
Dynamo (captures your Python bytecode into an FX graph — the hard part, because Python is
dynamic), the FX graph (a clean, inspectable IR — nodes and edges you can rewrite), and
Inductor (the default backend that lowers FX to Triton/C++ kernels). The magic and the pain
both live in Dynamo: when it can capture your whole function, you get one fast graph; when it
hits something it can't trace (data-dependent control flow, an unsupported op, a .item() call),
it graph-breaks — splits into multiple graphs with eager glue between them. Graph breaks are
the #1 reason torch.compile underdelivers, and finding/fixing them is a real skill.
The FX graph is where you'll spend your time, and here's the thing that will trip you, because
it just tripped this track's lab and I had to fix it: the same model produces different FX
graphs depending on how you capture it. symbolic_trace on nn.Linear/nn.GELU modules gives
you call_module nodes (target = the submodule name). Dynamo gives you a flattened graph of
function calls. AOT/make_fx gives you aten-level ops (aten.linear.default). A graph pass
written to match call_function aten ops will silently do nothing on a symbolic_trace graph
of modules — no error, just no fusion, no offload. The bug looks like "my optimization isn't
working" and the cause is "I'm pattern-matching the wrong graph dialect." A real backend must
handle the dialect it actually receives. Build the fusion pass, then test it on a module-based
graph and an aten graph, and feel the difference.
That fusion lab (fuse Linear+GELU into one node) is the core skill in miniature: pattern-match a subgraph, verify the match is safe to fuse, rewrite the graph, prove it's numerically identical. The "safe to fuse" part is where seniors separate from juniors — you can only fuse Linear→GELU if the Linear's output feeds only the GELU (single user). Fuse it when something else also reads that output, and you've changed the computation. The single-user check is the whole correctness argument. This pattern — match, check safety, rewrite, verify — is every graph optimization you'll ever write, including the ones that target the NPU.
The custom-backend lab is the payoff: register a backend, classify which nodes your "NPU" can run vs which fall back to CPU, transform the eligible ones, return a callable. This is exactly the shape of a real hardware backend. And the classification bug I fixed (matching aten strings when the graph had function-identity targets) is the real-world trap: your op-support table has to match the graph dialect, or you classify zero nodes as NPU-eligible and silently run everything on CPU — a "why is my accelerator doing nothing?" incident. CPU fallback is supposed to be a safety net, not the default because your matcher is broken.
The thing nobody tells you about torch.compile: correctness first, speed second, and verify
both. A backend that's fast but wrong is worse than useless — it ships silent accuracy bugs. So
every backend lab here ends with a numerical-equivalence check against eager. Internalize that
discipline: every graph transformation must be proven to preserve the output. At Qualcomm,
"the compiled model is 3× faster" is meaningless if you can't follow it with "and bit-exact to
eager within tolerance." The verification is not optional ceremony; it's the deliverable.
Career truth, brother: torch.compile / FX fluency is a differentiator because most ML
engineers treat the compiler as a black box they flip on. The person who can read an FX graph,
find the graph breaks, write a custom pass, and build a backend is operating at the layer
Qualcomm hires for — the layer where models meet silicon. And it's deeply transferable: the
same graph-rewriting mindset applies to ONNX (P05), to TVM, to MLIR, to any IR. Learn it once
here, recognize it everywhere.
Build the fusion pass. Build the backend. Make it classify NPU nodes correctly across graph dialects, and prove every transform is numerically equivalent. Then come to P05, where we go broader — the whole ML-compiler / IR landscape (ONNX, TVM, MLIR) that this is one instance of.
— your brother 👨🏻
Lab 01 — FX Graph Passes
Phase: 04 — torch.compile, FX Graph & Custom Backends | Difficulty: ⭐⭐⭐⭐☆ | Time: 3–4 hours
FX graph passes are how
torch.compiletransforms your model before lowering to a backend.
What you build
symbolic_tracea model to capture its FX graph- Write a graph pass that fuses
linear → reluinto a single node - Write a graph pass that eliminates redundant operations (dead code)
- Verify output numerical equivalence before and after transformation
Key concepts
| Concept | What to understand |
|---|---|
| FX IR | Three-address code representation of PyTorch operations |
| Node kinds | placeholder, get_attr, call_function, call_module, output |
| Graph rewriting | Insert/erase nodes; update users; maintain invariants |
| Operator fusion | Fused kernels reduce memory bandwidth (key for NPU perf) |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Lab 02 — Custom torch.compile Backend
Phase: 04 — torch.compile, FX Graph & Custom Backends | Difficulty: ⭐⭐⭐⭐⭐ | Time: 4–5 hours
Implement a custom backend that classifies ops as NPU-eligible or CPU-fallback and substitutes them in the FX graph.
What you build
- NPU op registry with
@register_npu_opdecorator classify_nodes— analyze FX graph for NPU vs CPU opstransform_graph— replace ATen ops with registered NPU ops via FX node substitutionnpu_backend—torch.compile(model, backend=npu_backend)callableverify_backend_correctness— compare eager vs compiled numerics
Key concepts
| Concept | What to understand |
|---|---|
| Backend callable | (gm: GraphModule, example_inputs) → Callable |
| ATen ops | aten.mm.default, aten.relu.default — canonical op names |
| Node replacement | graph.inserting_after(node) + node.replace_all_uses_with |
| Correctness gate | Max absolute diff < 1e-4 between eager and compiled |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Lab 03 — Triton Fused Kernel
Phase: 04 — torch.compile, FX Graph & Custom Backends | Difficulty: ⭐⭐⭐⭐⭐ | Time: 4–5 hours
Write a GPU kernel in Triton that fuses multiple operations into a single pass over memory.
What you build
- Triton kernel for fused
LayerNorm + Linearin a single kernel - Tile-based computation for cache efficiency
- Memory bandwidth analysis: fused vs unfused
- Benchmarking with
triton.testing.Benchmark
Key concepts
| Concept | What to understand |
|---|---|
| Triton programming model | Blocks of threads operate on tiles |
tl.load / tl.store | Coalesced memory access patterns |
| Fusion benefit | Avoid extra HBM round-trips for intermediate results |
@triton.jit | JIT-compiles kernel at first call |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite (CUDA-only) |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt # requires CUDA GPU
python lab.py
pytest test_lab.py -v
Phase 05 — ML Compilers & IRs
Difficulty: ⭐⭐⭐⭐⭐
Estimated Time: 3 weeks (70–90 hours)
Roles Supported: Framework & compiler engineers — required for writing NPU backend compilers, ONNX export pipelines, and cross-framework optimization toolchains
Why This Phase Exists
The ML compiler stack is the path from a PyTorch model to a running binary on a Qualcomm NPU. The chain is:
PyTorch Model
→ torch.export / torch.onnx.export
→ ONNX / MLIR / StableHLO (portable IRs)
→ SNPE/QNN compiler (Qualcomm-specific)
→ .dlc or .bin (NPU binary)
→ Hexagon runtime
Understanding each IR is not optional if you want to debug why a model produces wrong results after compilation, or why 3 ops are missing from the NPU coverage report, or why the compiled model is slower than expected despite the op coverage being 95%.
This phase builds working knowledge of ONNX, TVM, and MLIR — the three dominant ML compiler IRs in production systems. ONNX is what most pipelines export to. TVM is a complete compiler that demonstrates autotuning and schedule optimization. MLIR is the foundation of many new compilers including IREE (used for Qualcomm AI Engine).
Concepts
- ONNX (Open Neural Network Exchange): protobuf-based IR; nodes are
NodeProtowith op_type, inputs, outputs, attributes; opset versioning (opset 17 = standard); computation graph is a topological list of nodes - ONNX opset: each op version specifies exact semantics; mismatches between export opset and runtime opset cause failures
onnxoptimizer: graph-level rewrites for ONNX graphs (constant folding, reshape fusion, conv+BN fusion); similar to FX passes but for ONNXonnxruntime: Microsoft's ONNX execution engine; multiple execution providers (CPU, CUDA, CoreML, QNN)- TVM: complete ML compiler stack; Relay (high-level IR) → TIR (tensor IR) → codegen → machine code; includes AutoTVM (search-based schedule tuning) and Ansor (auto-scheduler)
- Relay IR: functional, typed, higher-order; supports data types, shapes, control flow; analogous to FX but more formal
- TIR (Tensor IR): imperative, loop-based; describes memory access patterns, loop tiling, vectorization; where most performance optimization happens
- Schedule in TVM: explicit loop transformation specification (tile, vectorize, parallelize, reorder, unroll); AutoTVM/Ansor searches the schedule space automatically
- MLIR (Multi-Level IR): modular IR framework; dialects define ops and types; transformation passes lower between dialects; core of LLVM 17+ and many ML compilers
- MLIR dialects:
linalg(named mathematical operations),affine(affine maps on memrefs),vector(SIMD),memref(memory regions),arith(scalar arithmetic),func(functions),tosa(Tensor Operator Set Architecture — used by TFLite and Qualcomm) - TOSA (Tensor Operator Set Architecture): small, well-defined op set targeting hardware accelerators; used by TFLite converter, IREE, and Qualcomm's pipeline
- IREE (Intermediate Representation Execution Environment): end-to-end ML compiler; ingests MLIR/StableHLO, outputs code for CPU/GPU/NPU; used for Qualcomm AI Engine deployments
- StableHLO: XLA's HLO dialect stabilized as a cross-framework standard; JAX/TF/PyTorch can all emit StableHLO
Labs
Lab 01 — ONNX Export Pipeline + Optimization Passes
| Field | Value |
|---|---|
| Goal | Build a complete ONNX export, validation, and optimization pipeline for LLaMA-style models; implement 3 custom graph rewrite passes; validate accuracy end-to-end |
| Concepts | torch.onnx.export, onnx.checker, onnxoptimizer, onnxruntime, opset compatibility, dynamic axes, custom ONNX operators |
| Steps | 1. Export LLaMA-tiny to ONNX with dynamic axes for batch and sequence; 2. Run onnx.checker.check_model() and fix any violations; 3. Implement 3 custom onnxoptimizer passes: (a) fuse consecutive Reshape ops, (b) eliminate identity ops, (c) fold constant Gather nodes; 4. Validate accuracy: ONNX Runtime output vs PyTorch within 1e-4 on 100 inputs; 5. Compare latency: PyTorch eager vs ONNX Runtime CPU vs ONNX Runtime CUDA |
| Stack | PyTorch 2.3+, onnx, onnxoptimizer, onnxruntime, onnxruntime-gpu |
| Datasets | Synthetic + WikiText-2 val (first 100 sequences) |
| Output | llama_tiny.onnx; optimized llama_tiny_opt.onnx; accuracy/latency comparison table; visualization of graph before/after optimization |
| How to Test | pytest test_lab.py — checks: ONNX model passes checker, output matches within 1e-4, optimized model has fewer nodes, dynamic axes work for different batch/seq sizes |
| Talking Points | What are dynamic axes in ONNX? Why does a Reshape → Reshape sequence arise from PyTorch export? How do custom ONNX ops work when the runtime doesn't support them? |
| Resume Bullet | Built ONNX export + optimization pipeline for LLaMA-style model; 3 custom graph passes reduced node count by 18%; ONNX Runtime achieved 1.3x latency improvement over PyTorch eager on CPU |
| Extensions | Add QDQ nodes for INT8 ONNX quantization; add TensorRT EP execution; integrate with ONNX Runtime's QNN Execution Provider |
Lab 02 — TVM Compilation Pipeline
| Field | Value |
|---|---|
| Goal | Compile a ResNet-50 and BERT-base through the full TVM pipeline (Relay → TIR → AutoTVM tuning → deploy), demonstrating 2x+ speedup over PyTorch eager via schedule optimization |
| Concepts | TVM Relay IR, relay.frontend.from_pytorch, relay.build, tuning records, autotvm.LocalRunner, ansor.TaskScheduler, TVM runtime module, cross-compilation for ARM |
| Steps | 1. Import PyTorch model to Relay IR via relay.frontend.from_pytorch; 2. Run shape inference and type checking; 3. Apply standard Relay optimizations (FuseOps, FoldConstant, EliminateCommonSubexpr); 4. Build without tuning (baseline); 5. Extract tuning tasks and run AutoTVM with 100 trials; 6. Build with tuning records; 7. Compare: PyTorch eager vs TVM untuned vs TVM tuned |
| Stack | TVM 0.15+, PyTorch 2.3+, torchvision |
| Datasets | ImageNet val subset (timing) |
| Output | TVM compiled modules for ResNet-50 and BERT-base; timing table showing speedup; tuning logs; cross-compilation target for llvm -mcpu=cortex-a55 (simulating ARM edge device) |
| How to Test | pytest test_lab.py — checks: Relay model passes type checker, output matches PyTorch within 1e-4, tuned model faster than untuned, cross-compile succeeds without actual ARM device |
| Talking Points | What is a schedule in TVM? How does AutoTVM search the schedule space? What is the Relay FuseOps pass and how does it decide what to fuse? |
| Resume Bullet | Compiled ResNet-50 through TVM with AutoTVM tuning; achieved 2.1x throughput improvement over PyTorch eager on x86 CPU; demonstrated ARM cross-compilation for edge deployment |
| Extensions | Add Ansor (auto-scheduler) comparison; enable CUDA VTA (virtual tensor accelerator) target; implement custom TIR pass for loop reordering |
Lab 03 — MLIR Dialect and Lowering Pass
| Field | Value |
|---|---|
| Goal | Write a simple MLIR transformation pass using Python bindings that lowers a subset of linalg ops to affine dialect; then lower to LLVM IR and compile to native code |
| Concepts | MLIR Python bindings (mlir-python-bindings), dialects, IR builder API, pass pipeline, linalg.generic, affine.for, memref.alloc, arith ops, bufferization |
| Steps | 1. Build a simple 3-op MLIR program (matmul + bias + relu) using Python bindings; 2. Print the linalg dialect representation; 3. Apply the standard lowering pipeline: linalg-bufferize → convert-linalg-to-affine → lower-affine → convert-to-llvm; 4. Write a custom pass: AffineLoopUnroll that unrolls innermost loops by factor 4; 5. JIT compile and execute via mlir.execution_engine.ExecutionEngine; 6. Verify output matches NumPy reference |
| Stack | mlir-python-bindings (from LLVM 17+), llvmlite |
| Datasets | Synthetic matrices |
| Output | Complete lowering pipeline from linalg → affine → llvm → native; custom unroll pass; JIT execution with correctness verification |
| How to Test | pytest test_lab.py — checks: linalg IR is valid, lowered affine IR has expected loop structure, JIT output matches NumPy within 1e-5, custom unroll reduces loop iteration count by 4× |
| Talking Points | What is a dialect in MLIR? How does progressive lowering work? Why does linalg.generic use an indexing_map? How does MLIR relate to Qualcomm's IREE/QNN compiler? |
| Resume Bullet | Built MLIR transformation pipeline with custom affine loop unroll pass; demonstrated linalg→affine→LLVM lowering for matmul+bias+relu, producing JIT-compiled code matching NumPy reference within 1e-5 |
| Extensions | Target TOSA dialect (for Qualcomm pipeline); write a SPIR-V lowering pass; implement vectorization pass using vector dialect |
Deliverables Checklist
- ONNX pipeline with 3 custom passes and accuracy/latency table
- TVM compilation with AutoTVM tuning showing ≥2× speedup
- MLIR lowering pipeline with custom pass and JIT execution
-
All
test_lab.pysuites pass
Interview Relevance
- "What is the difference between ONNX and MLIR?" — you can explain: ONNX = serializable exchange format, MLIR = compilation framework with progressive lowering; they solve different problems
- "How would you add a new op to the QNN compiler?" — you know to: register in ONNX (for export), implement in TOSA dialect (MLIR), register as SNPE custom op (C++)
- "Why does a model run correctly in PyTorch but produce NaN in TVM?" — you know to check: type casting (FP32 vs FP16 accumulation), padding behavior differences, reduction order numerics
Warmup Guide — ML Compilers & Intermediate Representations
Zero-to-expert primer for Phase 05. Builds the compiler view of ML from "what is an IR" through ONNX, TVM's Relay/TIR split, and MLIR's dialect system — assuming Phase 04's torch.compile vocabulary.
Table of Contents
- Chapter 1: What a Compiler IR Is and Why ML Needs Several
- Chapter 2: The Lowering Ladder
- Chapter 3: ONNX — The Interchange Layer
- Chapter 4: ONNX in Practice — Export, Opsets, and Optimization
- Chapter 5: TVM — Relay and TIR
- Chapter 6: Scheduling and Auto-Tuning
- Chapter 7: MLIR — Dialects and Progressive Lowering
- Chapter 8: Classic Optimizations Every ML Compiler Runs
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: What a Compiler IR Is and Why ML Needs Several
Zero background: a compiler transforms programs between representations. An intermediate representation (IR) is a program format designed for analysis and transformation rather than for humans: explicit dataflow, explicit types, no syntactic sugar. Classic example: LLVM IR sits between C++ and x86, and a hundred optimizations operate on it without caring about either end.
Why ML compilers need multiple IRs: optimization questions live at different altitudes.
- "Can I fuse conv+BN+ReLU?" is a graph-level question (operators as atoms).
- "How do I tile this matmul for a 1 MB SRAM?" is a loop-level question (the operator's internals as loops over indices).
- "Which vector instruction implements this multiply-accumulate?" is instruction-level.
One IR cannot serve all three: an IR that exposes loop indices destroys the operator structure graph passes need, and vice versa. So every serious stack is a stack of IRs with lowering steps between — Relay→TIR in TVM, FX→Inductor-IR→Triton in PyTorch, dialect→dialect in MLIR, ONNX→vendor-IR in every NPU toolchain (Phase 06's DLC is exactly this).
Chapter 2: The Lowering Ladder
Memorize this ladder; every toolchain in the field is an instance of it:
| Level | Unit of program | Example IRs | Optimizations that live here |
|---|---|---|---|
| Graph | operators | ONNX, Relay, FX, MLIR tosa/stablehlo | fusion, constant folding, layout (NCHW↔NHWC), quantization insertion, partitioning |
| Tensor/loop | loops + buffers | TIR, Inductor IR, MLIR linalg/affine | tiling, vectorization, loop reordering, memory planning, double buffering |
| Target | instructions | LLVM IR, PTX, Hexagon ISA | register allocation, instruction selection, scheduling |
Lowering is one-way information loss: once a matmul becomes three nested loops, "this was a matmul" is gone (and with it the option to call cuBLAS). Hence the design rule: optimize at the highest level where the optimization is expressible, lower as late as possible. When you see a vendor toolchain do something weird, the explanation is almost always "that information was lost a level above."
Chapter 3: ONNX — The Interchange Layer
What it is: a serialization format (protobuf) for the graph level — a list of nodes, each with an op type from a versioned standard library ("opset"), plus initializers (weights) and typed input/output value-infos. Not a runtime, not a compiler: a contract.
Why it exists: M frameworks × N deployment targets would need M×N converters; with a standard middle format it's M+N. Every NPU toolchain (Qualcomm's included) accepts ONNX precisely because of this economics.
The pieces that bite in practice:
- Opsets: the op library is versioned; ops change semantics between opsets (e.g. resize/upsample history). Exporter and consumer must agree; "convert to opset 17" is a real and lossy operation.
- Dynamic axes: dims may be symbolic ("batch"), but many embedded consumers require fully static shapes — you'll re-export with fixed shapes for NPUs.
- Functions vs primitives: composite ops (GELU) can export as one node (if the opset has it) or a subgraph of primitives — and which one you get changes what the downstream compiler can pattern-match. This is Chapter 2's information-loss principle in action.
- External data: weights >2 GB overflow protobuf and ship as side files.
Chapter 4: ONNX in Practice — Export, Opsets, and Optimization
Export from PyTorch is itself a capture problem (Phase 04!): the classic
torch.onnx.export traces — data-dependent control flow gets baked in; the modern
dynamo=True path uses TorchDynamo for honest capture. Either way: always validate
— run the ONNX model under onnxruntime on real inputs and compare to PyTorch within
tolerance (1e-5 FP32). Silent semantic divergence (training-mode BN, dropout left on,
baked branches) is the classic export bug family.
Graph optimization on ONNX (Lab 01's second half): constant folding, identity/dropout
elimination, conv-BN fusion, GELU/LayerNorm pattern fusion into single fused ops the
runtime has kernels for. onnxruntime applies these at session-load (graph_optimization_ level); onnxsim does shape-inference-driven simplification offline. The lab has you do
both and diff the node counts before/after — learn to read what the optimizer did, not
trust it blindly.
The accuracy discipline: every transformation step in a deployment pipeline gets a numeric diff gate (PyTorch → ONNX → optimized ONNX → quantized → NPU). When the final number is wrong, the bisection over stages is your debugging procedure — Phase 06 and the capstone build exactly this harness.
Chapter 5: TVM — Relay and TIR
TVM is the canonical open-source instance of the full ladder, which is why it's worth learning even if you deploy with vendor tools.
Relay (graph level): a typed functional IR — every tensor has a shape+dtype in the type system, so shape inference is type checking. Graph passes (fusion, layout transform, quantization) are Relay→Relay rewrites. (TVM's successor IR "Relax" adds first-class dynamic shapes; the concepts transfer.)
TIR (loop level): explicit loops, buffers, and indices. A matmul in TIR is the triple loop you'd write by hand — plus a schedule: a separable description of how to execute those loops (tile, reorder, vectorize, parallelize, bind to GPU blocks/threads).
The compute/schedule separation (TVM's foundational idea, from Halide): what is computed is written once; how is a list of transformations applied to the loop nest. Correctness lives in the compute; performance lives in the schedule; you can search over schedules without risking correctness. This idea — in various syntaxes — is in Inductor, Mojo, and every NPU compiler's tiling engine.
Chapter 6: Scheduling and Auto-Tuning
Why tiling works (the one transformation to deeply understand): a naive matmul streams entire rows/columns through registers, re-reading B from DRAM $O(N)$ times. Tile the loops into blocks sized to fit cache/SRAM and each block of B is loaded once per tile instead of once per element — DRAM traffic drops by the tile factor. On NPUs with software-managed SRAM (no cache to save you), tiling isn't an optimization, it's mandatory correctness — data must be explicitly staged.
The search problem: tile sizes × loop orders × vectorization × unrolling is a space of millions of schedules whose performance is non-convex and hardware-specific. AutoTVM searches it empirically: generate candidates, compile, measure on the real device, fit a cost model, iterate (auto-scheduler/Ansor generates the candidates structurally rather than from hand-written templates). Lab 02 runs this loop and asks you to inspect the winning schedule and explain why it won — measured search beating your intuition is the lesson.
Chapter 7: MLIR — Dialects and Progressive Lowering
The problem MLIR solves: everyone (TVM, XLA, every vendor) was rebuilding the same compiler infrastructure — parsers, pass managers, SSA, verifiers — for their own IR stack. MLIR is infrastructure for building IRs: one framework, many dialects.
A dialect is a namespaced set of ops + types + verification rules. The ones to recognize on sight:
stablehlo/tosa— graph-level ML ops (the ONNX-altitude layer)linalg— structured operations on tensors ("this is a matmul" preserved as a property — fusion and tiling are generic over all linalg ops)affine/scf— loops (affine = polyhedral-analyzable loops)memref— buffers with explicit layout (post-bufferization: tensors are SSA values; memrefs are memory)llvm— the exit to LLVM codegen
Progressive lowering: a compilation is a pass pipeline walking dialects downward
(stablehlo → linalg → scf → llvm), each step small and verifiable, mixed dialects
coexisting in one module mid-flight. This is Chapter 2's ladder made into a reusable
library — and it is what Qualcomm, Apple, and Google NPU compilers are actually built on,
which is why Lab 03 (IR reading/building via Python bindings) is career-relevant even
though you'll rarely write C++ passes yourself.
Reading MLIR (the lab's core skill): SSA values (%0), ops with fully-explicit types
(linalg.matmul ins(%A, %B : tensor<128x256xf32>, ...) outs(...)), regions (ops
containing blocks of ops — how control flow and loop bodies nest).
Chapter 8: Classic Optimizations Every ML Compiler Runs
Recognize these by their fingerprints in before/after IR dumps:
- Constant folding: subgraphs with all-constant inputs computed at compile time (weight transposes, shape arithmetic). Fingerprint: nodes vanish, initializers grow.
- CSE: identical computations deduplicated.
- DCE: ops whose results are unused removed (often exposes bugs — your "missing op" was dead code).
- Operator fusion: Phase 04 Chapter 9's memory-traffic argument; at this level done by pattern (vertical: producer→consumer chains; horizontal: same-shaped siblings batched).
- Layout assignment: NCHW vs NHWC vs blocked layouts (NC4HW4...) chosen per backend; transposes inserted at boundaries — minimizing those transposes is a real optimization problem and a recurring NPU profiling finding (Phase 06).
- Memory planning: liveness analysis over the graph → buffer reuse plan → peak memory. On NPUs this is again not optional: the plan must fit physical SRAM.
Lab Walkthrough Guidance
Order: Lab 01 (ONNX) → Lab 02 (TVM) → Lab 03 (MLIR) — interchange first, then one full stack, then the infrastructure generalization.
- Lab 01: export a small transformer; validate numerically before optimizing (the export bug families are the lesson). Then run the optimizer and produce a node-count + op-type diff table; find the conv-BN and GELU fusions in the diff.
- Lab 02: build the Relay graph, compile unscheduled, measure; then apply manual tiling, measure; then AutoTVM, measure. The three-point comparison (naive/manual/tuned) is the deliverable — keep the numbers.
- Lab 03: read before you write — dump linalg for a matmul and annotate every line.
Then build a small module with the Python bindings and run a pass pipeline
(
linalg → loops), diffing IR at each stage.
Success Criteria
You are ready for Phase 06 when you can, from memory:
- Draw the three-level lowering ladder and place ONNX, Relay, TIR, FX, linalg, and a vendor DLC on it.
- State the information-loss principle and give the matmul→loops→"can't call cuBLAS" example.
- Explain opsets and two real ONNX export bug families, with the validation discipline that catches them.
- Explain compute/schedule separation and why tiling reduces DRAM traffic with the block-reuse argument.
- Define an MLIR dialect, name five, and read a
linalg.matmulaloud. - Given an IR before/after dump, identify which classic optimization ran.
Interview Q&A
Q: Your ONNX model's accuracy differs from PyTorch by 2%. Walk through the debug. Bisect the pipeline: (1) raw export vs PyTorch on identical inputs — if differing, suspect capture (eval mode? dropout? data-dependent branch baked by trace? opset semantics like resize coordinate modes); (2) optimized vs raw ONNX — a bad fusion or folding (disable optimization levels to isolate); (3) if quantized, that's Phase 03's playbook. Always with fixed seeds and per-layer output diffs, not just end-to-end.
Q: Why do NPU compilers care so much about layout? The MAC array consumes data in a fixed blocked pattern; a mismatched layout means either transpose ops (DRAM round-trips) or strided access (wasted bandwidth). Layout assignment chooses one layout per island and pushes transposes to island boundaries — profiling that shows 30% time in transposes (a real Phase 06 finding) means the assignment failed.
Q: TVM's schedule vs torch.compile's Inductor — compare. Same separation, different defaults: TVM exposes scheduling as user-facing API + offline empirical search per target (minutes-to-hours of tuning, peak results, deploy-time philosophy); Inductor makes scheduling decisions with heuristics + limited autotuning at JIT time (seconds, near-peak, development-loop philosophy). NPU vendor compilers are TVM- shaped: offline, search-heavy, target-specific.
Q: Why did MLIR win over everyone building their own IR stack? Shared infrastructure (SSA, verification, pass management, location tracking) is ~80% of a compiler's engineering and 0% of its differentiation. Dialects let vendors keep their proprietary altitude (a custom NPU dialect) while reusing lowering to/from standard dialects — interop and hiring both improve. The bet: composable dialects beat monolithic IRs, and the ecosystem (stablehlo, IREE, torch-mlir, every new accelerator) confirmed it.
References
- ONNX specification and operators
- torch.onnx export docs (dynamo path)
- Chen et al., TVM: An Automated End-to-End Optimizing Compiler for Deep Learning (OSDI 2018)
- Ragan-Kelley et al., Halide (PLDI 2013) — the compute/schedule separation origin
- Zheng et al., Ansor: Generating High-Performance Tensor Programs (OSDI 2020)
- Lattner et al., MLIR: Scaling Compiler Infrastructure for Domain Specific Computation (CGO 2021)
- MLIR language reference and Toy tutorial
- onnxruntime graph optimizations
- Apache TVM documentation
The Hitchhiker's Guide — Phase 05: ML Compilers & Intermediate Representations
"ONNX is the lingua franca of ML models. It is also the language everyone speaks but nobody reads." — ML compiler team
Section 1: Why ML Compilers Exist
A PyTorch model is a Python program that calls CUDA kernels. This is flexible but suboptimal:
- Each op is a separate CUDA kernel launch (overhead: ~5 µs per launch)
- Elementwise op sequences allocate intermediate tensors (memory overhead)
- The scheduler doesn't know about future ops — can't make global decisions
ML compilers solve this by:
- Capturing the full computation graph (no Python interpreter overhead)
- Optimizing globally across ops (operator fusion, constant folding, layout optimization)
- Generating hardware-specific code (Triton, CUDA C, HTP microcode, LLVM IR)
The result: compiled models run 1.5–10× faster than eager mode, depending on how memory-bound the workload is.
Section 2: ONNX — Open Neural Network Exchange
What ONNX Is
ONNX is a graph-based IR (intermediate representation) with:
- Nodes: operations (Conv, Gemm, LayerNormalization, etc.)
- Inputs/Outputs: typed tensors with optional shape information
- Initializers: constant weights (stored in the proto)
- Opset: versioned operation set (opset 17 = ONNX spec version 17)
ONNX is a serialization format — it stores the graph as a .onnx file (protobuf binary) that any ONNX-compatible runtime can load.
Exporting from PyTorch
import torch
import torch.onnx
model = MyModel()
model.eval()
dummy_input = torch.randn(1, 3, 224, 224)
# Old API (still common)
torch.onnx.export(
model,
dummy_input,
"model.onnx",
opset_version=17,
input_names=["input"],
output_names=["output"],
dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}} # dynamic batch
)
# New API (torch.onnx.dynamo_export — uses torch.compile under the hood)
from torch.onnx import dynamo_export
export_output = dynamo_export(
model,
dummy_input,
export_options=torch.onnx.ExportOptions(dynamic_shapes=True)
)
export_output.save("model_dynamo.onnx")
Inspecting ONNX Graphs
import onnx
from onnx import numpy_helper
model = onnx.load("model.onnx")
onnx.checker.check_model(model) # validate the graph
# Inspect nodes
for node in model.graph.node:
print(f"Op: {node.op_type}, inputs: {list(node.input)}, outputs: {list(node.output)}")
# Inspect tensors
for init in model.graph.initializer:
arr = numpy_helper.to_array(init)
print(f"Weight: {init.name}, shape: {arr.shape}, dtype: {arr.dtype}")
# Use netron for visual inspection:
# pip install netron && netron model.onnx
ONNX Graph Optimization
from onnxoptimizer import optimize
# Standard optimization passes
optimized = optimize(model, [
'eliminate_unused_initializer',
'eliminate_nop_transpose',
'eliminate_nop_pad',
'fuse_bn_into_conv', # batchnorm → folded into conv weights
'fuse_consecutive_transposes',
'fuse_matmul_add_bias_into_gemm',
'fuse_pad_into_conv',
'eliminate_deadend',
])
# onnxsim (onnx-simplifier) — more aggressive
import onnxsim
simplified, check = onnxsim.simplify(model)
assert check, "Simplified model is invalid"
Pitfall: Not all ONNX opset versions are supported by all runtimes. QNN supports a subset of opset 17. Always validate with the target runtime after optimization.
ONNX Runtime
import onnxruntime as ort
import numpy as np
# CPU inference
session = ort.InferenceSession("model.onnx", providers=['CPUExecutionProvider'])
# CUDA inference
session = ort.InferenceSession("model.onnx", providers=['CUDAExecutionProvider', 'CPUExecutionProvider'])
# Run inference
input_name = session.get_inputs()[0].name
output = session.run(None, {input_name: np.random.randn(1, 3, 224, 224).astype(np.float32)})
Section 3: TVM — Tensor Virtual Machine
TVM is an open-source ML compiler that lowers a model graph → Relay IR → TE (Tensor Expressions) → TIR (Tensor IR) → backend code (LLVM, CUDA, Metal, HTP).
TVM Compilation Stack
ONNX / TorchScript / TFLite
↓
Relay IR (high-level graph IR)
↓
[Relay passes: fold, inline, quantize]
↓
TE (Tensor Expressions) per operator
↓
[AutoTVM / AutoScheduler — search for best schedule]
↓
TIR (Tensor IR — loop nest representation)
↓
[TIR passes: loop fusion, vectorization, unrolling]
↓
Backend code (LLVM, NVPTX, Metal, HEX)
Basic TVM Example
import tvm
from tvm import relay
import tvm.relay.testing
import onnx
# Load ONNX model into Relay
onnx_model = onnx.load("model.onnx")
shape_dict = {"input": (1, 3, 224, 224)}
mod, params = relay.frontend.from_onnx(onnx_model, shape_dict)
# Apply Relay optimization passes
seq = tvm.transform.Sequential([
relay.transform.InferType(),
relay.transform.FoldConstant(),
relay.transform.FastMath(),
relay.transform.FoldScaleAxis(),
relay.transform.CanonicalizeOps(),
relay.transform.AlterOpLayout(), # layout optimization (NHWC→NCHW, etc.)
])
with tvm.transform.PassContext(opt_level=3):
mod = seq(mod)
# Compile for target
target = tvm.target.cuda() # or "llvm" for CPU, "hexagon" for Qualcomm DSP
with tvm.transform.PassContext(opt_level=3):
lib = relay.build(mod, target=target, params=params)
# Deploy
dev = tvm.device(str(target), 0)
m = tvm.contrib.graph_executor.GraphModule(lib["default"](dev))
m.set_input("input", tvm.nd.array(np.random.randn(1, 3, 224, 224).astype("float32")))
m.run()
output = m.get_output(0).numpy()
TVM AutoScheduler (Ansor)
AutoScheduler searches for optimal tile sizes, unroll factors, vectorization decisions automatically — no hand-written schedules needed:
from tvm import auto_scheduler
log_file = "resnet50.json"
# Measure on hardware to find best schedule
tasks, task_weights = auto_scheduler.extract_tasks(mod["main"], params, target)
tuner = auto_scheduler.TaskScheduler(tasks, task_weights)
tune_option = auto_scheduler.TuningOptions(
num_measure_trials=200, # per task
runner=auto_scheduler.LocalRunner(repeat=3, min_repeat_ms=150),
measure_callbacks=[auto_scheduler.RecordToFile(log_file)],
)
tuner.tune(tune_option)
# Compile with tuned schedule
with auto_scheduler.ApplyHistoryBest(log_file):
with tvm.transform.PassContext(opt_level=3, config={"relay.backend.use_auto_scheduler": True}):
lib = relay.build(mod, target=target, params=params)
Section 4: MLIR — Multi-Level Intermediate Representation
MLIR is a compiler infrastructure (from LLVM project) that provides:
- Dialects: modular IR namespaces (e.g.,
linalg,arith,memref,gpu,tosa) - Transformations: composable passes that lower between dialects
- Extensibility: you can define a new dialect for your hardware
PyTorch uses MLIR internally (via torch-mlir and iree-compiler) for export to mobile and edge targets.
The Dialect Lowering Hierarchy
torch dialect (PyTorch ops: torch.aten.linear, torch.aten.relu)
↓ (torch-mlir)
tosa dialect (Tensor Operator Set Architecture — hardware-agnostic)
↓
linalg dialect (structured loops: linalg.matmul, linalg.generic)
↓
arith + memref (arithmetic + memory buffer ops)
↓
llvm dialect (LLVM IR)
↓
Target ISA (x86, ARM, HEX)
MLIR Python Bindings
from mlir.ir import Context, Module, InsertionPoint
from mlir.dialects import arith, func
with Context() as ctx:
module = Module.create()
with InsertionPoint(module.body):
# Define a function: (i32, i32) -> i32
@func.FuncOp.from_py_func(
arith.IntegerType.get_signless(32),
arith.IntegerType.get_signless(32)
)
def add(a, b):
result = arith.AddIOp(a, b)
return result.result
print(module)
# prints:
# module {
# func.func @add(%arg0: i32, %arg1: i32) -> i32 {
# %0 = arith.addi %arg0, %arg1 : i32
# return %0 : i32
# }
# }
torch-mlir: PyTorch → MLIR
import torch
import torch_mlir
model = MyModel()
model.eval()
# Export to MLIR (TOSA dialect)
module = torch_mlir.compile(
model,
torch.randn(1, 3, 224, 224),
output_type="tosa",
)
print(module.operation.get_asm())
# Export to MLIR (linalg-on-tensors)
module = torch_mlir.compile(
model,
torch.randn(1, 3, 224, 224),
output_type="linalg-on-tensors",
)
Section 5: Qualcomm-Specific Compiler Path
Qualcomm's compilation path for Snapdragon:
PyTorch model
↓ (torch.onnx.export or torch.export)
ONNX graph
↓ (qai-hub-models or SNPE converter)
QNN DLC (Deep Learning Container)
↓ (QNN runtime on device)
HTP (Hexagon Tensor Processor) execution
Key Qualcomm tools:
snpe-onnx-to-dlc: converts ONNX → SNPE DLC (older, SNPE 2.x)qnn-onnx-converter: converts ONNX → QNN model (newer, QNN SDK)qai_hub.upload_model(): upload to AI Hub for cloud compilation + profilingqai_hub.compile_model(): compile for specific device (e.g., "Snapdragon 8 Gen 3")
Qualcomm-specific constraints:
- Op support: not all ONNX ops are supported on HTP; check QNN op support matrix
- Data types: HTP prefers INT8/INT16 weights; FP16 ops run on HVX or Cortex-A cores
- Static shapes: HTP compilation requires static input shapes (no dynamic batch)
- Graph partitioning: ops not supported on HTP fall back to CPU; too many fallbacks → poor performance
Section 6: Interview Pitfalls
| Question | Wrong Answer | Right Answer |
|---|---|---|
| "What is ONNX?" | "A model format" | "A graph-based IR with versioned opsets; stores ops as protobuf nodes with typed tensor edges; portable across frameworks and runtimes" |
| "What does TVM's AutoScheduler do?" | "It optimizes the model" | "It searches over the space of loop transformations (tile sizes, unroll factors, vectorization, thread binding) for each operator, using ML-guided search (Ansor uses a learned cost model)" |
| "What is MLIR?" | "Another ONNX" | "Compiler infrastructure with composable dialects; PyTorch uses it via torch-mlir to lower to TOSA/linalg; not a single IR but a framework for building IR hierarchies" |
| "ONNX export failed with 'Unsupported op'. What do you do?" | "Use a different framework" | "Implement a custom ONNX symbolic for that op: register via torch.onnx.register_custom_op_symbolic(), map to an ONNX custom op node or decompose into supported ONNX ops" |
| "What is 'op fallback' on Qualcomm NPU?" | "The op runs slower" | "When QNN compiler can't run an op on HTP, it falls back to the CPU (Cortex-A). This causes CPU↔HTP data transfers which can be 100× more expensive than the op itself. Solution: replace unsupported ops or implement a custom HTP op" |
Section 7: Resources
- ONNX specification — https://onnx.ai/onnx/intro/concepts.html
- onnxoptimizer passes — https://github.com/onnx/optimizer/tree/master/onnxoptimizer/passes
- TVM tutorial — https://tvm.apache.org/docs/tutorial/index.html
- AutoScheduler (Ansor) paper — Lianmin Zheng et al., "Ansor: Generating High-Performance Tensor Programs for Deep Learning" (OSDI 2020)
- MLIR primer — https://mlir.llvm.org/docs/Tutorials/Toy/
- torch-mlir — https://github.com/llvm/torch-mlir
- QNN SDK documentation — https://docs.qualcomm.com/bundle/publicresource/topics/80-63442-2/
- "The Deep Learning Compiler: A Comprehensive Survey" — Li et al., 2021 (arXiv:2002.03794)
👨🏻 Brother Talk — Phase 05, Off the Record
ML compilers & IR — ONNX, TVM, MLIR. The JD lists "ML compiler workload synthesis" as a plus and "hardware software co-design" as preferred. This phase is that, made concrete.
Brother, here's the big picture nobody draws for you clearly: between your PyTorch model and the transistors on a Qualcomm SOC, there is a stack of intermediate representations, and the whole game of model deployment is lowering cleanly through them. PyTorch → FX/aten (P04) → ONNX → a hardware compiler IR (TVM's Relay/TIR, MLIR dialects) → the NPU's instruction set. Each level throws away some flexibility and gains some optimizability. The Senior Staff engineer is the person who understands the whole pipe and can debug a model that lowers fine until level 4 and then explodes. That's a rare and valuable shape of knowledge.
Start with the truth about ONNX, because it's the universal interchange format and the place
most deployment bugs surface: ONNX is a contract between frameworks. You export from PyTorch,
and the export either captures your model faithfully or it doesn't — and "doesn't" is common.
Unsupported ops, dynamic shapes that become fixed, control flow that doesn't survive, opset
version mismatches. The export lab will teach you that the export is where the accuracy bug is
born: a model that's perfect in PyTorch can drift after ONNX export because an op got
decomposed differently or a shape got specialized. The discipline — export, then verify
numerically against the original, then optimize — is the same correctness-first discipline as
P04, and it's the one that saves you. (And note: the dynamo-based exporter needs onnxscript,
optimization needs onnxoptimizer — these are real deps; on a bare machine the lab tells you
what's missing rather than lying.)
The mental model for IR design that'll serve you forever: an IR is a deliberate tradeoff between expressiveness and optimizability. High-level IR (ONNX, Relay) keeps operators coarse (a whole "conv" or "attention") — easy to pattern-match and rewrite, but coarse. Low-level IR (TIR, LLVM) is fine-grained loops and memory — you can optimize the daylights out of it, but you've lost the high-level structure. Lowering is the act of going from coarse to fine, and every lowering step is a chance to optimize and a chance to introduce a bug. MLIR's whole thesis is "don't pick one level — have many dialects and progressively lower through them." When you get the MLIR lab working (Python bindings), you're touching the framework that modern hardware compilers — including a lot of Qualcomm's world — are built on.
Here's the Qualcomm-specific intuition: operator coverage is destiny. Your fancy model is only as deployable as the least-supported op in it. One unsupported op on the NPU means a CPU fallback, which means a round-trip off the accelerator, which means latency death. So a huge part of this job is workload synthesis — understanding which operators the hardware supports, and either (a) rewriting the model to use supported ops, or (b) decomposing the unsupported op into supported ones, or (c) escalating to the hardware team that this op needs a kernel. The compiler is where you discover and fix the coverage gaps. That's the "co-design" the JD wants: you're not just consuming the hardware, you're feeding back what it needs to support.
TVM is worth your respect even though it may skip on this machine (it needs the TVM runtime). Its auto-tuning idea — search the space of loop tilings/schedules to find the fastest kernel for this hardware — is the conceptual ancestor of a lot of modern compiler optimization. You don't need to run it to understand the lesson: the optimal kernel schedule is hardware-specific and often non-obvious, so let a search find it. That mindset (measure, don't guess; search the space) is the bridge to the profiling phase (P07).
The thing nobody tells you about compiler work: it's debugging, not building. You'll spend 90% of your time figuring out why a lowering went wrong — why this op didn't fuse, why this shape got specialized, why the numerics drifted at this level — and 10% writing the pass. So the skill that matters is reading IR and bisecting the pipeline: dump the IR at each level, find the level where it first goes wrong, and you've localized the bug. The person who can read an ONNX graph or an MLIR dump and say "ah, the problem is here" is the person who unblocks the whole deployment.
Career truth, brother: ML-compiler skills are a genuine moat. The pool of people who understand PyTorch internals (P01/P04) and the compiler/IR stack and the hardware target is tiny — and that intersection is exactly the Qualcomm Senior Staff role. Most ML people stop at the framework; most systems people don't know the ML. You're building the rare combination. Lean into the "lowering through IRs" mental model — it's the spine that connects everything from torch.compile to the NPU.
Build the ONNX export, verify it numerically, optimize it. Read an MLIR dump. Then come to P06, where the IR finally meets the metal — the Qualcomm NPU itself.
— your brother 👨🏻
Lab 01 — ONNX Export & Optimization
Phase: 05 — ML Compilers & IR | Difficulty: ⭐⭐⭐⭐☆ | Time: 3–4 hours
ONNX is the universal interchange format between training frameworks and inference runtimes (TensorRT, ONNX Runtime, CoreML, QNN).
What you build
- Export PyTorch model to ONNX opset 17
- Apply graph optimization passes (constant folding, op fusion)
- Validate numerical equivalence PyTorch ↔ ONNX Runtime
- Measure latency: PyTorch eager vs ORT vs optimized ORT
Key concepts
| Concept | What to understand |
|---|---|
| ONNX opset | Versioned set of ops — opset 17 is required for newer transformer ops |
torch.onnx.export | Traces model; use dynamo_export for torch.compile models |
| ORT optimizations | OrtOptimizationLevel.ORT_ENABLE_ALL applies graph-level rewrites |
| Dynamic shapes | dynamic_axes allows variable batch/sequence length |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Lab 02 — TVM Compilation
Phase: 05 — ML Compilers & IR | Difficulty: ⭐⭐⭐⭐⭐ | Time: 4–5 hours
Apache TVM compiles ML models to optimized native code for CPU, GPU, and custom accelerators.
What you build
- Import PyTorch model into TVM Relay IR
- Apply TVM optimization passes (fusion, layout transform, quantization)
- Compile to native code with
tvm.build - Benchmark TVM vs PyTorch on target hardware
- Analyze Relay IR for supported vs unsupported ops
Key concepts
| Concept | What to understand |
|---|---|
| Relay IR | Functional IR; higher-level than LLVM IR |
| TE (Tensor Expressions) | Schedule-based computation description |
| AutoTVM / MetaSchedule | ML-guided kernel auto-tuning |
| Compilation pipeline | relay.optimize → te.create_prim_func → tvm.build |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite (skipped if TVM not installed) |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt # TVM requires separate install: see tvm.apache.org
python lab.py
pytest test_lab.py -v
Lab 03 — MLIR Python Bindings
Phase: 05 — ML Compilers & IR | Difficulty: ⭐⭐⭐⭐⭐ | Time: 4–5 hours
MLIR is the compiler infrastructure backing IREE, XLA, and Qualcomm's QNN compiler.
What you build
- Create MLIR modules and functions using Python bindings
- Build
linalg.matmulandarith.addfIR programmatically - Count and classify ops in an MLIR module (with regex fallback for environments without MLIR installed)
- Convert PyTorch FX graph to MLIR
func.funcdialect - Apply MLIR transformation passes
Key concepts
| Concept | What to understand |
|---|---|
| Dialects | Namespaced op sets: func, arith, linalg, memref, tosa |
| MLIR vs LLVM IR | MLIR is multi-level; ops can be progressively lowered |
tosa dialect | Target-agnostic ops used by CoreML, QNN, IREE |
| Lowering passes | linalg → loops → llvm — each step is a dialect transformation |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt # mlir-python-bindings optional
python lab.py
pytest test_lab.py -v
Phase 06 — Qualcomm NPU & Hardware Accelerator Architecture
Difficulty: ⭐⭐⭐⭐⭐
Estimated Time: 3 weeks (70–90 hours)
Roles Supported: Directly differentiating for Qualcomm roles — this is the company-specific stack
Why This Phase Exists
Every other phase in this curriculum applies broadly to AI engineering. This phase is specifically about Qualcomm's AI stack and the knowledge that makes you indispensable for this exact role.
Qualcomm's Snapdragon silicon contains three AI accelerators: the Adreno GPU, the Qualcomm Kryo CPU, and the Hexagon DSP with the HTP (Hexagon Tensor Processor) — an NPU. The HTP achieves >100 TOPS for INT8 operations and is the target for every deployed AI model on a Snapdragon device (900 million+ devices in 2024).
The deployment pipeline is:
- PyTorch/TF model → ONNX or TFLite
- ONNX → SNPE / QNN (Qualcomm Neural Processing SDK / Qualcomm AI Engine Direct)
- QNN SDK performs quantization + graph partitioning → DLC (Deep Learning Container)
- At runtime: Hexagon Runtime executes on HTP
Understanding where models fail in this pipeline — why 3% of ops fall back to CPU, why accuracy drops 2% after HTP quantization, why throughput is 40% of theoretical — is the entire job. This phase teaches you to reason about all of these.
Concepts
- Qualcomm AI Engine stack:
SNPE(older, simpler) →QNN(newer, more flexible) →QAI AppBuilder(high-level Python API); all three are in production - HTP (Hexagon Tensor Processor): matrix accelerator inside the Hexagon DSP; operates on INT8/INT16/FP16; has VTCM (Vector Tightly Coupled Memory) = fast on-chip SRAM analogous to GPU shared memory
- VTCM: ~8MB on Snapdragon 8 Gen 3; weights that fit in VTCM avoid DRAM bandwidth bottleneck; tiling strategy must account for VTCM capacity
- QNN Execution Providers: CPU, GPU, HTP (NPU), DSP; operations automatically or manually partitioned across backends
- DLC format: Qualcomm's compiled model format — weights + compute graph + quantization params; analogous to TensorRT
.engine snpe-onnx-to-dlc: command-line tool that converts ONNX to DLC; accepts quantization encoding files- QNN SDK: lower-level than SNPE; more control over graph partitioning, custom ops, and execution context; requires C++ SDK for custom operators
- Hardware-aware graph partitioning: assign each op to the device (CPU/GPU/HTP) that executes it most efficiently; account for: (1) HTP-supported ops, (2) data transfer cost between devices, (3) memory constraints
- NPU operator constraints: HTP has strict op constraints: weight shape must be aligned (e.g., multiples of 32), certain combination of dtypes not supported, some ops (e.g., scatter, dynamic shapes) always fall back to CPU
- HTP quantization encoding: QNN uses an
.encodingsJSON file specifying per-layer scale/offset for weights and activations; generated by thesnpe-dlc-quantizeorqnn-onnx-convertertools - Hexagon SDK: C/C++ SDK for writing custom HTP ops as "HVX (Hexagon Vector eXtensions)" kernels; SIMD intrinsics for 128-byte vectors
- Qualcomm AI Hub: cloud service for testing models on real Snapdragon devices without owning hardware; free tier available; used in Labs when physical hardware is unavailable
Labs
Lab 01 — SNPE/QNN Deployment Pipeline
| Field | Value |
|---|---|
| Goal | Build a complete SNPE/QNN deployment pipeline: PyTorch → ONNX → DLC → quantized DLC → benchmark accuracy and latency on Qualcomm AI Hub |
| Concepts | snpe-onnx-to-dlc, snpe-dlc-quantize, encodings JSON, Qualcomm AI Hub Python API, accuracy delta FP32 vs INT8 on HTP |
| Steps | 1. Export MobileNetV3-Small to ONNX (opset 13); 2. Generate quantization encodings using qai_hub.quantize_model() with 512 calibration images; 3. Convert ONNX to DLC using snpe-onnx-to-dlc; 4. Apply INT8 quantization to DLC; 5. Submit profiling job to Qualcomm AI Hub targeting Snapdragon 8 Gen 3 simulator; 6. Compare accuracy: PyTorch FP32 vs DLC FP32 vs DLC INT8 on ImageNet val-100 |
| Stack | qai_hub (Qualcomm AI Hub SDK), snpe (or QAI AppBuilder), PyTorch 2.3+, ONNX |
| Datasets | ImageNet val-100 (first 100 images per class) |
| Output | .dlc artifacts; AI Hub profiling report; accuracy/latency comparison table; ops fallback analysis (which ops run on HTP vs CPU) |
| How to Test | pytest test_lab.py — checks: ONNX passes checker, DLC file size < 2x ONNX (quantization should reduce size), accuracy delta < 1%, AI Hub job completes successfully |
| Talking Points | Why does .dlc require an encodings file? What causes ops to fall back to CPU in the QNN pipeline? How do you debug a DLC that produces different results than PyTorch? |
| Resume Bullet | Built end-to-end SNPE/QNN deployment pipeline for MobileNetV3; validated <0.8% accuracy delta under INT8 quantization on Snapdragon 8 Gen 3; achieved 4.2ms inference latency |
| Extensions | Deploy LLaMA-3.2-1B to AI Hub; add custom op using QNN SDK; implement pipeline for Qualcomm Cloud AI 100 (data center NPU) |
Lab 02 — Hardware-Aware Graph Partitioning
| Field | Value |
|---|---|
| Goal | Write a graph partitioner that analyzes an ONNX graph and assigns each op to CPU, GPU, or HTP; minimize estimated total latency accounting for device execution time and data transfer cost between devices |
| Concepts | Op capability tables, data transfer latency model, networkx for graph analysis, integer linear programming (ILP) for optimal partitioning, heuristic greedy partitioner |
| Steps | 1. Build HTPCapabilityTable — dict mapping ONNX op_type to supported/unsupported + precision support; 2. Build DeviceLatencyModel — parameterized cost function for each device per op type and shape; 3. Build TransferCostModel — latency to transfer tensor between CPU↔HTP given size; 4. Implement GreedyPartitioner — assign ops to HTP if supported and no costly data transfer boundary; 5. Implement ILPPartitioner using scipy.optimize.linprog for optimal solution; 6. Compare greedy vs ILP on 5 model architectures; 7. Visualize partitioning as colored graph |
| Stack | PyTorch 2.3+, onnx, networkx, scipy, matplotlib |
| Datasets | ONNX graphs of: MobileNetV3, ResNet-50, BERT-base, LLaMA-tiny, ViT-Small |
| Output | Partition assignments for 5 models; greedy vs ILP latency comparison; colored graph visualization |
| How to Test | pytest test_lab.py — checks: partition is valid (no cycles across device boundaries), all ops assigned, ILP solution is optimal (better or equal to greedy), transfer boundaries counted correctly |
| Talking Points | Why is the partitioning problem NP-hard? When does greedy outperform ILP (hint: overhead)? How does the actual QNN SDK handle partitioning? |
| Resume Bullet | Implemented hardware-aware graph partitioner for QNN deployment; demonstrated ILP-optimal partitioning reduces estimated cross-device transfer cost by 31% vs greedy baseline on transformer models |
| Extensions | Add VTCM budget constraint (weights that fit must stay on HTP); add pipeline parallelism (CPU and HTP running simultaneously); implement dynamic profiling using AI Hub timing data |
Lab 03 — NPU Operator Mapping & Fallback Strategy
| Field | Value |
|---|---|
| Goal | For a given model (Whisper-small for speech, LLaMA-3.2-1B for text), systematically identify unsupported NPU ops, develop workaround strategies, and measure accuracy/latency impact of each workaround |
| Concepts | Op decomposition (replace unsupported op with supported primitives), op fusion (combine two ops into one supported op), fallback policies (per-layer, per-batch, always-CPU), custom HTP op stubs |
| Steps | 1. Run model through QAI Hub to get op support report; 2. Classify unsupported ops by category: (a) decomposable, (b) fusible with adjacent op, (c) must fall back to CPU; 3. Implement decomposition rewrites for top-3 unsupported ops (e.g., GeLU → polynomial approx, LayerNorm → simpler form); 4. Measure accuracy delta of each decomposition on task metric; 5. Implement fallback cost estimator; 6. Produce final recommendation report: which ops to decompose, which to fall back, expected end-to-end latency |
| Stack | qai_hub, onnx, PyTorch 2.3+, onnxoptimizer |
| Datasets | LibriSpeech test-clean (for Whisper); MMLU (for LLaMA) |
| Output | Op support report for 2 models; decomposition implementations; accuracy table before/after decomposition; final deployment recommendation |
| How to Test | pytest test_lab.py — checks: decomposed models are ONNX valid, GeLU approximation error < 1e-3, fallback cost correctly accounts for CPU↔HTP data transfer |
| Talking Points | How does GeLU polynomial approximation compare to erf-based implementation on an NPU? What are the risks of operator decomposition for accuracy? When would you reject a decomposition? |
| Resume Bullet | Systematically resolved NPU op compatibility for Whisper-small on Snapdragon 8 Gen 3; 3 operator decompositions reduced CPU fallback rate from 22% to 7% with <0.4% WER accuracy impact |
| Extensions | Write a custom HVX kernel for an unsupported op; submit custom op to QNN SDK via NHWC layout; implement a regression test comparing HTP vs CPU output for all fallback ops |
Deliverables Checklist
- End-to-end SNPE/QNN pipeline with AI Hub profiling report
- Graph partitioner (greedy + ILP) with visualization
- Op mapping analysis with decomposition strategies for 2 models
-
All
test_lab.pysuites pass
Interview Relevance
- "How would you deploy a new model to our Snapdragon NPU?" — you have the exact pipeline: PyTorch → ONNX → QNN conversion → encodings → DLC → AI Hub validation
- "Our model loses 3% accuracy after HTP quantization. What's your debugging process?" — you check: op fallbacks, per-layer quantization error, activation outliers, calibration dataset quality
- "What is VTCM and why does it matter for model performance?" — you can explain tiling strategy, DRAM bandwidth bottleneck, and the latency cliff when weights don't fit
Warmup Guide — Qualcomm NPU Hardware & Deployment
Zero-to-expert primer for Phase 06. Builds the NPU mental model from "why accelerators exist" through Hexagon/HTP architecture, the SNPE/QNN toolchains, AI Hub, hardware-aware quantization, and on-device profiling.
Table of Contents
- Chapter 1: Why NPUs Exist — The Energy Argument
- Chapter 2: Anatomy of an NPU
- Chapter 3: The Snapdragon Compute Complex
- Chapter 4: The Toolchain — SNPE, QNN, and AI Hub
- Chapter 5: Graph Compilation for NPUs
- Chapter 6: Hardware-Aware Quantization
- Chapter 7: On-Device Profiling — What the Numbers Mean
- Chapter 8: The Fallback Problem
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Why NPUs Exist — The Energy Argument
Zero background: a phone has a thermal budget of ~3–5 W sustained. Energy per operation, roughly (45nm-era figures from Horowitz, still directionally right):
| Operation | Energy |
|---|---|
| INT8 add | ~0.03 pJ |
| FP32 multiply | ~3.7 pJ |
| SRAM read (32b) | ~5 pJ |
| DRAM read (32b) | ~640 pJ |
Two conclusions define the entire field:
- DRAM access costs ~100× the arithmetic. Accelerator design is therefore memory choreography — keep data on-chip, reuse it maximally. Compute is nearly free.
- INT8 is ~10–30× cheaper than FP32 in energy and silicon area. That is why Phase 03 (quantization) is the entry fee for NPU deployment, not an optimization.
A CPU spends most of its silicon on flexibility (caches, branch prediction, out-of-order machinery). An NPU spends it on exactly one thing: dense low-precision multiply- accumulate with software-managed data movement. Same transistor budget, ~10–100× the inferences per joule — for the workloads it fits.
Chapter 2: Anatomy of an NPU
The recurring blueprint (Qualcomm HTP, Apple ANE, Google Edge TPU all rhyme):
- MAC array: a grid of multiply-accumulate units (systolic array or vector-tensor hybrid). Peak TOPS = MACs × 2 × clock. Peak is achieved only when the array is fed — utilization is the real metric.
- Tightly-coupled SRAM (TCM), single-digit MB: software-managed, not a cache. The compiler must explicitly stage tiles in and out (Phase 05's tiling chapter is literal here). If a layer's working set exceeds TCM, it gets split — or spills to DRAM and performance falls off the energy cliff above.
- Vector unit (HVX on Hexagon): wide SIMD for the ops that aren't matmuls — elementwise, normalization reductions, requantization scaling.
- Scalar core + DMA engines: orchestration and asynchronous DRAM↔TCM transfers, double-buffered so compute overlaps movement.
Dataflow taxonomy (worth knowing the words): weight-stationary (weights pinned in the array, activations stream — good for conv), output-stationary (accumulators pinned), row-stationary (Eyeriss's hybrid). The choice determines which reuse pattern is free and which costs bandwidth — and explains per-op efficiency differences you'll see in profiling.
Chapter 3: The Snapdragon Compute Complex
A Snapdragon SoC offers four compute targets, and placement is a first-class decision:
| Unit | Character | Right workload |
|---|---|---|
| Kryo CPU | flexible, lowest throughput | control flow, preprocessing, tiny models, fallback ops |
| Adreno GPU | FP16-friendly, mid throughput/efficiency | float models, image ops, when NPU coverage is poor |
| Hexagon HTP (NPU) | INT8/INT4 + limited FP16, highest TOPS/W | quantized CNNs/transformers — the deployment target |
| Sensing hub | always-on, microwatts | wake-word, low-rate sensing |
The Hexagon HTP is the "fused AI accelerator": scalar threads + HVX vector + tensor accelerator sharing TCM. Generations matter operationally — INT4 weight support, FP16 paths, and per-op coverage vary by SoC generation, which is why runtime capability query + per-device validation is part of any serious deployment pipeline (and why AI Hub's device farm exists).
Chapter 4: The Toolchain — SNPE, QNN, and AI Hub
Three layers, frequently confused:
- QNN (Qualcomm AI Engine Direct): the low-level per-backend API — a graph API with op packages per target (HTP, GPU, CPU). Vendor-IR altitude: you hand it a graph, it compiles a context binary for a specific SoC.
- SNPE: the older/higher-level SDK — converts ONNX/TFLite into DLC (Deep Learning Container) files, with runtime selection (CPU/GPU/DSP-HTP) at load. Still everywhere in production; conceptually a vendor compiler whose input is ONNX (Phase 05's ladder, made concrete).
- Qualcomm AI Hub (what Lab 01 uses): cloud service — submit a PyTorch/ONNX model, it compiles for a chosen device (via QNN/TFLite/ONNX-RT paths), runs it on real hardware in a device farm, and returns artifacts + per-layer profiles. The fastest legitimate way to get real-device numbers without a bench full of phones.
The pipeline shape (memorize; it generalizes to every NPU vendor):
train (PyTorch) → export (ONNX) → quantize (calibration data!) → compile (per-SoC binary) → validate accuracy on-device → profile → iterate. Every arrow is a place
accuracy or performance silently changes; the capstone builds gates at each arrow.
Chapter 5: Graph Compilation for NPUs
What the NPU compiler does with your graph (all Phase 04/05 concepts, specialized):
- Partitioning: which ops run on HTP vs fall back (Chapter 8).
- Layout assignment: HTP consumes blocked/channel-aligned layouts; transposes appear at partition boundaries. Channel counts that aren't multiples of the vector width get padded — a 3-channel input conv wastes lanes; a 36-channel depthwise layer may run at half the efficiency of a 32-channel one. Architecture choices echo in silicon.
- Fusion: conv+requant+activation chains fused to avoid TCM round-trips; the requantization step (INT32 accumulator → INT8 output via scale multiply) is fused into the MAC epilogue.
- Tiling & memory planning: split every op so working sets fit TCM; plan double-buffered DMA. Failures here appear as "this one layer is 10× slower" — usually a spill.
- Scheduling: order islands to overlap DMA with compute.
You don't write these passes — but you read their output (profiles, op coverage reports) and you change the model to make them succeed. That model-side empathy is the differentiating skill this phase trains.
Chapter 6: Hardware-Aware Quantization
Phase 03 taught quantization math; the hardware adds constraints (Lab 02's content):
- Symmetric weights, asymmetric activations (typical HTP-friendly config) — zero-point multiplies in the inner loop are silicon cost; weight zero-points are usually forced to 0.
- Power-of-two vs arbitrary scales: some pipelines prefer shift-friendly scales for the requantization step; arbitrary scales need a fixed-point multiply.
- Per-channel weights are supported; per-channel activations are not — activation outliers must be solved at the model level (SmoothQuant/AWQ, Phase 03 Ch. 9), not by finer activation granularity.
- INT16 activations (where supported) as the accuracy escape hatch for sensitive layers (softmax inputs, attention scores, SSM states) at ~2× cost — mixed-precision within the graph is a per-op placement problem.
- BN folding and cross-layer equalization (CLE) before calibration — AIMET-style pre-processing that equalizes per-channel ranges across consecutive layers, often worth several accuracy points on mobile CNNs before any fancier method.
- The golden rule: quantize with the same convention the hardware executes (rounding mode, accumulator width, requant order). A simulator that rounds differently than silicon produces accuracy numbers that lie — this is why on-device validation (Lab 01/03) is non-negotiable.
Chapter 7: On-Device Profiling — What the Numbers Mean
The profile (AI Hub returns these per layer) and how to read it (Lab 03):
- End-to-end latency ≠ Σ layer times: includes runtime dispatch, DMA, and inter-partition transfers. A big gap = orchestration overhead, usually fallbacks.
- First-inference vs steady-state: graph init, weight loading, and warmup (clock ramp) dominate call #1; report steady-state median + p99, never single runs.
- Per-layer time: rank, then compare against a roofline estimate (Phase 07): a layer at 5% of peak is mis-tiled, spilling, padded, or on the wrong unit. The top-5 layers usually hold 80% of the opportunity.
- Sustained vs burst: phones throttle. A 5 ms inference at burst clock is a 9 ms inference after two minutes of thermal soak — measure both regimes; products live in sustained.
- Memory traffic counters (where exposed): the DRAM-bytes-per-inference number converts directly to energy (Chapter 1) — often a better optimization target than latency itself.
Chapter 8: The Fallback Problem
When the NPU can't run an op (unsupported type, dynamic shape, exotic op), the runtime falls back to GPU/CPU. The cost is not the op itself — it's the boundary: flush NPU pipeline, transfer activations (possibly with layout conversion + dequantize), run elsewhere, transfer back, requantize. One fallback in the middle of a graph can cut partition sizes such that end-to-end latency doubles, while every individual layer "looks fine".
Fallback triage (the single most common real-world NPU performance bug):
- Get the partition report (which ops, which runtime, how many islands).
- If islands > ~2–3, find the breakers: usually shapes (dynamic dims, rank >4), exotic ops (erf-GELU, complex slicing), or dtype transitions.
- Fix at the model level: replace the op (tanh-GELU), make shapes static, move the offending op to the graph edge (pre/post-processing), or decompose it into supported primitives (Phase 04 Lab 02's judgment call).
Lab Walkthrough Guidance
Order: Lab 01 (AI Hub deployment) → Lab 02 (hardware-aware quantization) → Lab 03 (on-device profiling) — get a model on real silicon first, then make it accurate, then make it fast.
- Lab 01: take the smallest model that's interesting (MobileNet-class or a tiny transformer); run the full pipeline; keep every artifact (ONNX, compiled binary, accuracy numbers at each stage) — the pipeline discipline is the deliverable. No AI Hub account? The lab's mock path exercises identical structure.
- Lab 02: start from your Phase 03 PTQ pipeline; add the hardware constraints (symmetric weights, per-tensor activations); measure the accuracy delta each constraint costs; then add CLE/bias-correction and measure the recovery.
- Lab 03: profile before reading the code, rank layers, predict the cause for the top 3 (roofline + Chapter 5 reasoning), then check. Train the prediction muscle, not the lookup muscle.
Success Criteria
You are ready for Phase 07 when you can, from memory:
- Reproduce the energy table's two conclusions (DRAM ~100× arithmetic; INT8 ~10–30× FP32) and derive accelerator design from them.
- Sketch the NPU anatomy (MAC array, TCM, vector unit, DMA) and explain why TCM is software-managed.
- Name the four Snapdragon compute targets and a placement rule for each.
- Walk the deployment pipeline naming what can silently break at each arrow.
- List four hardware quantization constraints that don't exist in Phase 03's pure math.
- Run the fallback triage procedure on a partition report from memory.
Interview Q&A
Q: Model hits 30% of advertised TOPS. Diagnose. TOPS assumes the MAC array is saturated with INT8 at burst clock. Check, in order: (1) fallbacks fragmenting the graph (partition report); (2) bandwidth-bound layers — roofline says they can't reach compute peak (decode-phase LLMs live here); (3) tiling/spills — per-layer profile shows the outlier; (4) layout/padding waste from awkward channel counts; (5) thermal state — sustained vs burst. Most real cases are (1) or (2); say that.
Q: INT8 model is accurate in simulation, wrong on device. Why? Convention mismatch between simulator and silicon: rounding mode (round-half-even vs half-up), accumulator saturation behavior, requantization order (scale-then-add vs add-then-scale), or a fused activation clamping at different points. Validate layer-by- layer on device against the simulator to find the first diverging layer, then read that op's hardware spec. This is why the pipeline keeps per-layer dump hooks.
Q: Which layers would you keep at higher precision in a transformer on an NPU, and why? Softmax inputs/outputs and attention score paths (small dynamic range errors change rankings), LayerNorm/RMSNorm statistics, the router in MoE, and any recurrent state (Mamba). These are cheap (small tensors) but sensitivity-critical — classic mixed- precision placement: spend bits where error propagates, not where tensors are big.
Q: Why does Qualcomm care that you understand model architecture (Phase 02) for an NPU role? Because the cheapest fixes are model-side: replacing erf-GELU with tanh-GELU removes a fallback; choosing GQA shrinks the KV cache that DMA must stream; padding-friendly channel counts recover MAC utilization; static shapes unlock compilation. The NPU engineer who can negotiate with the model ships; the one who only files compiler bugs waits.
References
- Horowitz, Computing's Energy Problem (and what we can do about it) — ISSCC 2014 keynote (the energy table)
- Sze et al., Efficient Processing of Deep Neural Networks: A Tutorial and Survey (2017) — arXiv:1703.09039
- Chen et al., Eyeriss: A Spatial Architecture for Energy-Efficient Dataflow (ISCA 2016) — dataflow taxonomy
- Jouppi et al., In-Datacenter Performance Analysis of a Tensor Processing Unit (ISCA 2017) — the systolic-array reference
- Qualcomm AI Hub documentation
- Qualcomm AI Engine Direct (QNN) docs
- AIMET — AI Model Efficiency Toolkit — CLE, bias correction, AdaRound
- Nagel et al., Data-Free Quantization Through Weight Equalization and Bias Correction (2019) — arXiv:1906.04721
The Hitchhiker's Guide — Phase 06: Qualcomm NPU Hardware & Deployment
"Understanding Snapdragon is not optional for this role. It is the product." — Qualcomm Engineering
Section 1: Snapdragon SoC Architecture
The Snapdragon 8 Gen 3 (SM8650) is a heterogeneous SoC with multiple compute units:
┌─────────────────────────────────────────────────────────────┐
│ Snapdragon 8 Gen 3 │
│ │
│ ┌──────────────────┐ ┌──────────────────────────────┐ │
│ │ CPU (Kryo) │ │ GPU (Adreno 750) │ │
│ │ 1×Cortex-X4 │ │ Vulkan/OpenCL compute │ │
│ │ 5×Cortex-A720 │ │ ~3.8 TFLOPS FP16 │ │
│ │ 2×Cortex-A520 │ └──────────────────────────────┘ │
│ └──────────────────┘ │
│ ┌──────────────────────────────┐ │
│ ┌──────────────────┐ │ HTP (Hexagon Tensor Proc.) │ │
│ │ DSP (Hexagon) │ │ ~73 TOPS INT8 │ │
│ │ HVX (512-bit │ │ VTCM: 8 MB on-chip SRAM │ │
│ │ vector unit) │ │ HMX (Hexagon Matrix Ext.) │ │
│ └──────────────────┘ └──────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ DRAM (LPDDR5X @ 77 GB/s, 12 GB shared) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
HTP (Hexagon Tensor Processor) Deep Dive
The HTP is the AI accelerator on Snapdragon. Its key components:
HMX (Hexagon Matrix Extension): matrix multiply unit; processes INT8 8×8 matrix blocks in a single cycle; achieves ~73 TOPS INT8 throughput.
HVX (Hexagon Vector Extension): 512-bit SIMD vector unit; 64× INT8 multiplies per cycle; used for elementwise ops, activations, normalization.
VTCM (Vector Tightly Coupled Memory): 8 MB on-chip SRAM connected directly to HMX/HVX datapaths; bandwidth: ~2 TB/s (vs DRAM's 77 GB/s); critical: weights that fit in VTCM during a layer's computation avoid the 26× DRAM bandwidth penalty.
Tiling for VTCM: the QNN compiler tiles large weight matrices to fit in VTCM. For example, a 4096×4096 INT8 matrix = 16 MB — doesn't fit in 8 MB VTCM. The compiler tiles into 4 strips of 4096×1024 = 4 MB each, processes sequentially, accumulates output.
Power states: HTP has multiple power levels (turbo, nominal, SVS, SVS2). At SVS, throughput drops to ~20 TOPS but power consumption drops by 5×. The QNN runtime can switch states dynamically.
Section 2: SNPE vs QNN — When to Use Which
| Feature | SNPE (Snapdragon NPE SDK) | QNN (Qualcomm Neural Network SDK) |
|---|---|---|
| Generation | 2nd gen | 3rd gen (current) |
| Supported devices | Snapdragon 855–888+ | Snapdragon 8 Gen 1+ |
| Backend | CPU, GPU, DSP, NSP | CPU, GPU, HTP, sHTP |
| Model format | DLC | Model + Backend Libraries |
| Quantization | Per-layer INT8 | Per-channel INT8/INT16/FP16 |
| Dynamic shapes | Limited | Better support |
| Recommendation | Legacy/embedded | Use for new projects |
SNPE Conversion Pipeline
# Convert ONNX → SNPE DLC
snpe-onnx-to-dlc \
--input_network model.onnx \
--output_path model.dlc \
--input_dim input "1,3,224,224"
# Quantize with calibration data
snpe-dlc-quantize \
--input_dlc model.dlc \
--input_list calibration_list.txt \
--output_dlc model_quantized.dlc
# Profile on device (connected Android device)
snpe-net-run \
--container model_quantized.dlc \
--input_list input_list.txt \
--perf_profile high_performance \
--backend HTP \
--profiling_level detailed
QNN SDK Pipeline
# Convert ONNX → QNN
# From command line:
# qnn-onnx-converter --input_network model.onnx --output_path model_qnn
# Compile for device
# qnn-model-lib-generator --model model_qnn.cpp --lib_name model_qnn
# Run with QNN runtime
# qnn-net-run --model libmodel_qnn.so --backend libQnnHtp.so --input_list inputs.txt
Section 3: Qualcomm AI Hub — Cloud-Based Testing
Qualcomm AI Hub allows submitting models for profiling on real Snapdragon devices without owning one. It is the primary tool for the labs in this course.
import qai_hub
# Upload model
model = qai_hub.upload_model("model.onnx") # or torch model, tflite, etc.
# Compile for device
compile_job = qai_hub.submit_compile_job(
model=model,
device=qai_hub.Device("Snapdragon 8 Gen 3"),
input_specs={"input": ((1, 3, 224, 224), "float32")},
)
compiled_model = compile_job.get_target_model()
# Profile
profile_job = qai_hub.submit_profile_job(
model=compiled_model,
device=qai_hub.Device("Snapdragon 8 Gen 3"),
)
profile_result = profile_job.download_profile()
# Inspect per-layer timing
for layer in profile_result["layers"]:
print(f"{layer['name']:40s} {layer['execution_time_us']:8.1f} µs {layer['compute_unit']}")
# Inference
inference_job = qai_hub.submit_inference_job(
model=compiled_model,
device=qai_hub.Device("Snapdragon 8 Gen 3"),
inputs={"input": [np.random.randn(1, 3, 224, 224).astype(np.float32)]},
)
outputs = inference_job.download_output_data()
Reading the Profile Output
The profile output from AI Hub contains:
- layer name: maps back to ONNX node name
- execution_time_us: time in microseconds on the target device
- compute_unit: "HTP" (good), "CPU" (bad — means HTP fallback), "GPU"
- data_type: INT8 (fast), FP16 (slower on HTP)
Optimization workflow:
- Run initial profile → identify top-5 slowest layers
- Check compute_unit for each slow layer — if "CPU", that op is falling back
- For HTP fallbacks: check QNN op support matrix, then either (a) decompose the unsupported op, (b) replace with a supported alternative, or (c) quantize the op to INT8 (many FP32 ops unsupported on HTP but INT8 variants are)
- For slow HTP layers: check data type — FP16 is 4× slower than INT8 on HMX
Section 4: Op Mapping — PyTorch to HTP
| PyTorch Op | ONNX Op | HTP Support | Notes |
|---|---|---|---|
nn.Linear | Gemm | ✅ INT8/FP16 | Primary ML op |
nn.Conv2d | Conv | ✅ INT8/FP16 | |
nn.LayerNorm | LayerNormalization | ✅ FP16 | Slower than BN |
nn.BatchNorm2d | BatchNormalization | ✅ INT8 | Fuse into Conv |
torch.softmax | Softmax | ✅ FP16 | |
torch.sigmoid | Sigmoid | ✅ INT8/FP16 | |
torch.relu | Relu | ✅ INT8/FP16 | |
F.gelu | Gelu | ✅ FP16 | |
nn.MultiheadAttention | Attention (contrib) | ⚠️ Partial | Use QNN's custom attention kernel |
torch.einsum | N/A | ❌ | Decompose to matmul + transpose |
torch.unique | Unique | ❌ | CPU fallback — avoid in hot path |
tensor.nonzero() | NonZero | ❌ | Dynamic shape — CPU only |
| Custom Python ops | N/A | ❌ | Always CPU — eliminate these |
F.scaled_dot_product_attention | Attention | ✅ FP16 | Use directly |
Key insight: dynamic-shape ops (NonZero, Unique, scatter with dynamic indices) are almost always CPU-only. Design models to avoid them in the forward pass.
Section 5: Hardware-Aware Quantization for HTP
HTP achieves peak throughput with INT8 weights AND INT8 activations. Mixed-precision strategies:
Per-channel quantization (preferred for weights):
- Each output channel has its own scale/zero_point
- Better accuracy than per-tensor for weights
- Supported: INT8 per-channel for all linear/conv ops
Per-tensor quantization (for activations):
- Single scale/zero_point for the entire tensor
- Required for activations (per-channel activation quant not standard)
INT16 activations (Qualcomm extension):
- Some ops support INT16 activations with INT8 weights
- Better accuracy for sensitive layers (attention logits)
- 2× memory cost for activations vs INT8
Quantization-aware calibration on Qualcomm:
import qai_hub
import numpy as np
# Use 128–512 calibration samples (representative of deployment distribution)
calibration_data = {
"input": [sample.numpy() for sample in calibration_dataset[:128]]
}
compile_job = qai_hub.submit_compile_job(
model=model_onnx,
device=qai_hub.Device("Snapdragon 8 Gen 3"),
input_specs={"input": ((1, 3, 224, 224), "float32")},
options="--quantize_full_type int8 --quantize_io", # INT8 weights and activations
calibration_data=calibration_data,
)
Section 6: Power and Thermal Constraints
On mobile, thermal and power matter as much as performance:
- TDP: Snapdragon 8 Gen 3 sustained power ≈ 5W; burst ≈ 15W
- Thermal throttling: if SoC temperature > 85°C, clock speeds drop automatically
- Performance hints:
qai_hub.Deviceoptions includeperformance_profile(burst, sustained, low_power, power_saver) - Battery vs latency: at low_power mode, throughput ≈ 25% of burst, but battery drain ≈ 10% of burst
Engineering trade-off: at Qualcomm, the optimization target is often ops/joule (efficiency), not ops/second (throughput). A model that runs at 80% of peak TOPS but uses 40% of peak power may be preferred.
Section 7: Interview Pitfalls
| Question | Wrong Answer | Right Answer |
|---|---|---|
| "What is the HTP?" | "The GPU on Snapdragon" | "Hexagon Tensor Processor — a dedicated ML accelerator with HMX (matrix unit) + HVX (vector unit) + 8 MB VTCM on-chip SRAM; achieves ~73 TOPS INT8 on 8 Gen 3" |
| "Why does an op fall back to CPU on HTP?" | "It's not supported" | "Specific reasons: (1) op has dynamic shapes, (2) op is not in QNN's HTP op library for that data type, (3) data type mismatch (FP32 ops unsupported, use FP16 or INT8)" |
| "What is VTCM?" | "Video RAM" | "Vector Tightly Coupled Memory — 8 MB on-chip SRAM attached directly to HMX/HVX with ~2 TB/s bandwidth; weights processed from VTCM are 26× faster than from DRAM" |
| "SNPE vs QNN?" | "SNPE is newer" | "SNPE is the legacy SDK (2nd gen); QNN is the current SDK (3rd gen) with better quantization, wider device support, and more op coverage. Use QNN for new projects" |
| "How do you optimize a model for HTP throughput?" | "Quantize to INT8" | "Systematically: (1) profile to find slow/CPU-fallback layers, (2) eliminate dynamic-shape ops, (3) quantize to INT8 for HMX throughput, (4) check VTCM tiling for large weight tensors, (5) use QNN's fused attention op instead of decomposed attention" |
Section 8: Resources
- Qualcomm AI Hub documentation — https://aihub.qualcomm.com/docs
- QNN SDK — https://docs.qualcomm.com/bundle/publicresource/topics/80-63442-2/
- Qualcomm Innovation Center — AI Models — https://github.com/quic/ai-hub-models (100+ models with AI Hub integration)
- "Dissecting the Qualcomm Snapdragon NPU" — AnandTech deep-dive
- SNPE documentation — https://docs.qualcomm.com/bundle/publicresource/topics/80-63442-50/
- Hexagon DSP SDK — https://developer.qualcomm.com/software/hexagon-dsp-sdk
- qai-hub-models GitHub — https://github.com/quic/ai-hub-models — production examples of PyTorch → HTP deployments
👨🏻 Brother Talk — Phase 06, Off the Record
The NPU itself — the metal this whole role exists to serve. The JD: "best suited for Qualcomm's SOCs," "hardware accelerators," "hardware software co-design." This is home turf.
Brother, every phase before this was preparing you to understand this: the actual silicon. A Qualcomm NPU (the Hexagon Tensor Processor / HTP in the AI Engine) is not a tiny GPU. It's a different kind of machine with a different personality, and if you bring GPU instincts here you'll make slow, wrong decisions. The Senior Staff engineer is the one who has internalized what the NPU loves and what it hates, and shapes models accordingly. Let me give you that personality profile, because it's the heart of the job.
What the NPU loves: static shapes, dense regular dataflow, fixed-point (INT8/INT4) math, ops it has dedicated hardware for, and computation that stays on-chip. What it hates: dynamic shapes, data-dependent control flow, irregular memory access (gather/scatter — hello MoE routing), unsupported ops, and anything that forces a round-trip to the CPU or to DRAM. Hold this contrast and half your design decisions make themselves. That gorgeous MoE from P02? The routing is a gather the NPU hates. That Mamba scan? Sequential, awkward. The "best" model on a GPU leaderboard might be the worst thing you could put on this hardware — and knowing that, and having the NPU-friendly alternative ready, is your entire value.
The single most important number in your life now is NPU coverage (or "offload rate"): what fraction of your model's compute actually runs on the NPU vs falls back to CPU. The labs compute this, and you should treat it like a vital sign. Low coverage is a silent killer — the model "works," the demo runs, but it's secretly bouncing half its ops to the CPU, and your latency and power are wrecked. One unsupported op in a hot loop can tank the whole thing because of the fallback round-trips. The Senior Staff move: profile coverage first, find the fallback ops, and eliminate them (rewrite, decompose, or get a kernel written). "Why is my model slow on the NPU?" is almost always "your coverage is 60% and you didn't know it."
The thing nobody tells you about hardware deployment: the simulator and the silicon disagree, and both lie a little. You'll develop against AI Hub / a simulator because you can't always have the device, and the simulator is directionally right but not bit-exact on timing or sometimes on numerics. The discipline is: validate accuracy on the simulator, but trust the device for performance numbers, and always reconcile when they diverge. The on-device profiling lab is where this gets real — the gap between "the sim said 5ms" and "the phone says 12ms" is where the actual engineering happens (thermal throttling, memory bandwidth, the DSP being busy with something else).
Hardware-aware quantization (the lab) is where P03 meets P06 and gets real. Textbook quantization optimizes math error; hardware quantization optimizes what the NPU can actually execute fast. The NPU wants specific bit-widths, specific symmetric/asymmetric conventions, specific per-tensor-vs-per-channel layouts, and it has a fixed-point accumulator with finite width that can overflow if your scales are off. The quantization scheme that's mathematically optimal might be one the hardware runs slowly or not at all. So you co-design: pick the quantization the silicon loves, then recover the accuracy with the techniques from P03. That loop — hardware constraint → quantization choice → accuracy recovery — is "model accuracy and AI performance" as a job title.
On power, because it's the constraint that makes mobile different: on a phone, joules are the budget, not just milliseconds. A model that's fast but power-hungry drains the battery and thermally throttles — and a throttled NPU is a slow NPU, so power and latency are coupled in a nasty feedback loop. The best engineers think in energy per inference, not just latency. This is foreign to data-center ML people and it's a place you can shine.
Now the honest bit about the labs: some of these need the Qualcomm SDK / AI Hub (the deployment lab may skip without it). That's expected — you can't ship the proprietary toolchain. But read every line, internalize the workflow (export → compile for the target → quantize for the hardware → profile coverage → profile latency → iterate), and understand the concepts (coverage, fallback, fixed-point accumulation, on-chip memory) so that when you're handed real hardware, you already know the moves. The concepts transfer; the SDK is just the steering wheel.
Career truth, brother: this is the rarest and most defensible skill in the whole track, because you cannot learn it from papers — it requires the hardware mindset that most ML engineers never develop. The person who deeply understands a specific accelerator's personality, who thinks in coverage and joules, who co-designs models with the silicon instead of throwing models at it — that person is irreplaceable at a hardware company. This is the phase that makes you a Qualcomm engineer specifically, not just an ML engineer generally. Own it.
Internalize the NPU's loves and hates. Make coverage a vital sign. Then come to P07, where we get ruthless about measurement — profiling and performance engineering, where you stop guessing and start knowing where the time goes.
— your brother 👨🏻
Lab 01 — AI Hub Deployment (QAI Hub)
Phase: 06 — Qualcomm NPU Hardware & Deployment | Difficulty: ⭐⭐⭐⭐⭐ | Time: 3–4 hours
Qualcomm AI Hub (qai_hub) compiles and benchmarks models directly on Snapdragon NPU/DSP hardware.
What you build
- Export a model to ONNX (opset 17) for QAI Hub compatibility
- Simulate SNPE/QNN conversion pipeline (profile, compile, inference tasks)
- Evaluate model compatibility: supported ops vs fallback ops
- Parse and analyze Hub job results for latency and accuracy
Key concepts
| Concept | What to understand |
|---|---|
| SNPE vs QNN | SNPE = legacy DLC format; QNN = new binary context format |
| NPU targets | Snapdragon 8 Gen 3 (SM8650) has 45 TOPS NPU |
| Op support | Not all ONNX ops supported on NPU; unsupported fall to CPU/GPU |
| QAI Hub jobs | compile_job → profile_job → inference_job pipeline |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Note: Full QAI Hub tests require a
qai_hubaccount and device access. Tests that need account access are skipped automatically.
Lab 02 — Hardware-Aware Quantization
Phase: 06 — Qualcomm NPU Hardware & Deployment | Difficulty: ⭐⭐⭐⭐⭐ | Time: 3–4 hours
NPU quantization requires hardware-specific constraints: INT8 weights, INT8 activations, symmetric quantization, and specific range policies.
What you build
- Hardware-constrained quantizer: symmetric INT8, power-of-2 scales
- Per-layer sensitivity analysis (which layers tolerate INT4 vs need INT8)
- Mixed-precision assignment policy based on accuracy drop thresholds
- Validate quantized model against NPU op constraints
Key concepts
| Concept | What to understand |
|---|---|
| Symmetric quant | NPU typically requires symmetric (zero_point=0) — simpler HW |
| Power-of-2 scales | Some NPUs require scales = 2^k for efficient shifting |
| Per-layer sensitivity | Measure ‖W_q @ X - W @ X‖ per layer to find sensitive layers |
| Mixed precision | INT4 for tolerant layers, INT8 for sensitive — maximize compression |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Lab 03 — On-Device Profiling
Phase: 06 — Qualcomm NPU Hardware & Deployment | Difficulty: ⭐⭐⭐⭐⭐ | Time: 3–4 hours
Understanding performance bottlenecks requires reading profiling traces from the NPU/DSP timeline.
What you build
- Parse simulated Snapdragon profiler JSON output
- Build a roofline model for NPU (compute-bound vs memory-bound classification)
- Identify bottleneck layers from profiling data
- Generate optimization recommendations: quantization, op fusion, layout
Key concepts
| Concept | What to understand |
|---|---|
| Roofline model | Peak TOPS vs memory BW — which resource is the bottleneck? |
| Arithmetic intensity | FLOPS / bytes moved — must exceed ridge point for compute-bound |
| HTA (Hexagon Tensor Accelerator) | Qualcomm's NPU — operates on INT8/INT4 fixed-point |
| DSP vs NPU | Some ops run on HVX DSP (vector); others on HTA (tensor) |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Phase 07 — Profiling & Performance Engineering
Difficulty: ⭐⭐⭐⭐⭐
Estimated Time: 3 weeks (70–90 hours)
Roles Supported: All senior ML roles — the ability to identify and fix performance bottlenecks is a core Senior Staff competency
Why This Phase Exists
"The model is slow" is not an actionable bug report. "The model's matrix multiplications are compute-bound at 45% utilization and the LayerNorm ops are memory-bound at 12% roofline efficiency" is. Senior Staff engineers don't guess at performance problems — they measure.
This phase teaches the systematic methodology: (1) measure theoretical limits with the roofline model, (2) profile actual execution to find the gap, (3) identify the bottleneck category (memory-bound vs compute-bound vs launch-overhead-bound), (4) apply the correct optimization, (5) verify the fix. Every production ML team at every hardware company does this. At Qualcomm, this applies to both CUDA profiling (R&D) and HTP profiling (product).
Concepts
- Roofline model: performance ceiling = min(peak FLOPS, peak memory bandwidth × arithmetic intensity); ops below the "roofline" are inefficient; ops at the roofline are optimal
- Arithmetic intensity (AI): FLOPS / bytes_transferred; compute-bound ops have high AI (matmul: O(n)/O(n²) = O(n)); memory-bound ops have low AI (elementwise: 1 FLOP / 8 bytes)
- Compute ceiling: peak FLOPS for a device = cores × clock × FLOPs-per-cycle; A100 = 312 TFLOPS FP16
- Memory bandwidth ceiling: peak DRAM bandwidth; A100 HBM = 2 TB/s; Snapdragon 8 Gen 3 DRAM ≈ 77 GB/s
nsys(Nsight Systems): timeline profiler; shows CUDA API calls, kernel launches, data transfers, CPU-GPU synchronization; best for understanding overall execution structurencu(Nsight Compute): per-kernel profiler; shows occupancy, memory throughput, warp stalls, FLOP counts; best for diagnosing individual kernel inefficiencytorch.profiler: PyTorch's built-in profiler; traces op-level timing with CUDA kernels;with torch.profiler.profile(activities=...)context managertorch.utils.benchmark:Timerclass for reliable benchmarking; handles warmup, multi-run statistics, eliminates timing noise- CUDA occupancy: fraction of max concurrent warps scheduled; low occupancy = wasted SM cycles; limited by: register pressure, shared memory usage, block size
- Memory coalescing: adjacent threads accessing adjacent memory → single wide DRAM transaction; non-coalesced access → N separate transactions → N× bandwidth waste
- Warp divergence: threads in the same warp taking different branches → lanes serialized; common cause of GPU inefficiency in sparse or conditional code
- CPU-GPU synchronization overhead: each
tensor.item(),tensor.numpy(), print of GPU tensor causes synchronization; destroys async execution pipeline - HTP profiling: Qualcomm AI Hub provides per-layer timing breakdown; analyze with AI Hub Python API; identify HTP vs CPU fallback ops
Labs
Lab 01 — Roofline Analysis Tool from Scratch
| Field | Value |
|---|---|
| Goal | Build a roofline_analyzer tool that (1) measures a GPU's peak FLOPS and memory bandwidth empirically, (2) instruments a PyTorch model to measure actual FLOP count and memory traffic per op, and (3) plots ops on the roofline diagram with bottleneck classification |
| Concepts | Memory bandwidth measurement (large memcpy benchmark), FLOP counting via torch.profiler, arithmetic intensity per op, roofline visualization, bottleneck classification |
| Steps | 1. Implement measure_peak_bandwidth(): time a large memcpy (e.g., 1 GB) and compute GB/s; 2. Implement measure_peak_flops(): time a large matmul and compute TFLOPS; 3. Implement count_flops_per_op(): use torch.profiler + flop_count_utils to get FLOP count for each op; 4. Implement measure_memory_traffic(): estimate bytes read/written per op from tensor sizes; 5. Implement plot_roofline(): matplotlib scatter plot with compute/memory ceilings; color code ops: red=compute-bound, blue=memory-bound, gray=launch-overhead-bound; 6. Run on: a single matmul (should be compute-bound), LayerNorm (should be memory-bound), full LLaMA-tiny forward pass |
| Stack | PyTorch 2.3+, CUDA GPU, matplotlib, torch.utils.flop_counter |
| Datasets | Synthetic |
| Output | RooflineAnalyzer class with analyze(model, inputs) method; roofline plot as PNG; per-op FLOPS, bandwidth, arithmetic intensity, bottleneck label |
| How to Test | pytest test_lab.py — checks: bandwidth measurement within 10% of spec, matmul is correctly classified as compute-bound, LayerNorm as memory-bound, plot PNG generated |
| Talking Points | "What is the arithmetic intensity of a transformer attention layer?" (depends on batch/seq, explain the crossover point), "How would you improve a memory-bound op?" (tiling, fusion), "What happens to arithmetic intensity at larger batch sizes?" |
| Resume Bullet | Built GPU roofline analysis tool from scratch; identified that 73% of ops in a production transformer are memory-bound at <15% roofline efficiency, leading to 2.1× optimization via kernel fusion |
| Extensions | Add HTP roofline using AI Hub timing data; add memory access pattern visualization; add ETE (Execution Time Estimator) combining roofline per op |
Lab 02 — Custom PyTorch Profiling Harness
| Field | Value |
|---|---|
| Goal | Build a production-quality profiling harness that traces: op-level timing, CUDA kernel timing, memory allocation/free events, and CPU-GPU synchronization points — all without modifying model code |
| Concepts | torch.profiler.profile, ProfilerActivity, CUPTI (CUDA Profiling Tools Interface) traces, torch.profiler.record_function, Chrome trace JSON, torch.cuda.memory_stats(), synchronization detection |
| Steps | 1. Implement ProfilingContext wrapper that enables torch.profiler with CUDA activities; 2. Implement MemoryTracker hook that logs allocation/free per tensor; 3. Implement SyncDetector that patches tensor.item(), tensor.numpy(), cuda.synchronize() to emit warnings; 4. Implement ProfileReport.generate() that produces: top-10 ops by CUDA time, memory peak, sync count, Chrome trace JSON; 5. Run on ResNet-50, BERT-base, and a deliberately bad model (has many .item() calls) |
| Stack | PyTorch 2.3+, torch.profiler, json |
| Datasets | Synthetic |
| Output | ProfilingContext class; Chrome trace JSON loadable in chrome://tracing; console report with top-10 slow ops, memory peak, sync violations |
| How to Test | pytest test_lab.py — checks: sync detector fires on .item() call, memory tracker reports peak within 5% of actual CUDA memory, trace JSON is valid Chrome format |
| Talking Points | "What is the CUPTI overhead of profiling?" (5–10% slowdown), "How do you profile a model in production without the profiler overhead?" (statistical sampling), "What is the typical sync budget for a training step?" |
| Resume Bullet | Built custom profiling harness that detected 47 synchronization violations in a production model; eliminating them reduced step time by 23% |
| Extensions | Add torch.autograd.profiler integration for gradient timing; add distributed profiling for multi-GPU; add NPU trace visualization |
Lab 03 — End-to-End Bottleneck Identification & Optimization
| Field | Value |
|---|---|
| Goal | Apply the full profiling methodology to a deliberately sub-optimal transformer inference implementation: profile → classify bottlenecks → apply targeted optimizations → verify improvements reach 80%+ of theoretical maximum |
| Concepts | KV cache impact, attention memory complexity, torch.compile speedup, operator fusion opportunities, batch size effects on arithmetic intensity, memory layout (contiguous vs non-contiguous tensors) |
| Steps | 1. Start with a naive transformer implementation with 5 seeded performance bugs: (a) no KV cache, (b) non-contiguous attention output, (c) CPU↔GPU sync in generation loop, (d) LayerNorm computed in FP32 even for FP16 model, (e) sequential instead of batched generation; 2. Profile with Lab 02 harness to find all 5; 3. Fix each bug; 4. Apply torch.compile; 5. Compare: buggy vs fixed vs compiled; document speedup for each fix |
| Stack | PyTorch 2.3+, Lab 01 and 02 harnesses |
| Datasets | WikiText-2 (for generation timing) |
| Output | Before/after timing table for each of 5 optimizations; cumulative speedup chart; final inference throughput vs theoretical peak |
| How to Test | pytest test_lab.py — checks: KV cache produces identical output, no sync violations in fixed model, compiled model faster than fixed-only, generation throughput improvement documented |
| Talking Points | Walk through each bug from the interview answer perspective: "I've seen production models with these exact issues. Here's how I found and fixed them." |
| Resume Bullet | Profiled and optimized transformer inference implementation; identified 5 performance bugs via systematic profiling; total 4.7× throughput improvement (KV cache: 2.1×, sync removal: 1.4×, compile: 1.6×) |
| Extensions | Add continuous batching; add FP8 computation; measure the speedup from FlashAttention (preview for Phase 08) |
Deliverables Checklist
- Roofline analyzer with plot for LLaMA-tiny (compute-bound vs memory-bound op classification)
- Profiling harness with Chrome trace output
- Before/after optimization table showing 4× cumulative speedup
-
All
test_lab.pysuites pass
Interview Relevance
- "Our model achieves 30% of peak FLOPS on A100. Where would you start looking?" — you walk through the roofline analysis: arithmetic intensity, memory-bound ops, kernel launch overhead
- "What tools do you use for GPU profiling?" — you describe the tool stack:
nsysfor timeline,ncufor kernel,torch.profilerfor Python-level; explain when to use each - "How do you measure the impact of a performance change reliably?" — explain warmup, multi-run averaging,
torch.utils.benchmark.Timer, preventing benchmarking artifacts
Warmup Guide — Profiling & Performance Engineering
Zero-to-expert primer for Phase 07. Builds performance analysis from "what limits speed" through the roofline model, profiler mechanics, and the taxonomy of transformer performance bugs.
Table of Contents
- Chapter 1: The Two Ceilings — Compute and Bandwidth
- Chapter 2: Arithmetic Intensity
- Chapter 3: The Roofline Model
- Chapter 4: FLOP and Byte Counting for Real Models
- Chapter 5: Prefill vs Decode — The Two Regimes of LLM Inference
- Chapter 6: How Profilers Work
- Chapter 7: Measurement Methodology — How Not to Lie to Yourself
- Chapter 8: The Transformer Performance-Bug Taxonomy
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: The Two Ceilings — Compute and Bandwidth
Zero background: any processor has two hard limits:
- Peak compute: how many floating/integer operations per second the ALUs can retire (e.g., A100: 312 TFLOPS FP16; a phone NPU: ~10–45 TOPS INT8).
- Peak memory bandwidth: how many bytes per second DRAM can deliver (A100: ~2 TB/s HBM; a phone: ~50–100 GB/s LPDDR).
Every kernel needs both: bytes in, operations on them, bytes out. Whichever resource the kernel exhausts first is its bound. A bandwidth-bound kernel cannot be sped up by faster ALUs, more cores, or lower precision compute — only by moving fewer bytes. The single most common performance-engineering error is optimizing the resource that wasn't the bottleneck; the whole point of this phase is to identify the bound before acting.
Chapter 2: Arithmetic Intensity
Definition: for a kernel,
$$I = \frac{\text{FLOPs performed}}{\text{bytes moved to/from DRAM}} \quad [\text{FLOP/byte}]$$
The machine balance point: a device with peak compute $P$ and bandwidth $B$ has ridge point $I^* = P/B$. If $I < I^$, the kernel is bandwidth-bound; if $I > I^$, compute-bound. A100 FP16: $312,\text{TFLOPS} / 2,\text{TB/s} \approx 156$ FLOP/byte — a high bar.
Canonical intensities (FP16, derive these yourself once):
- Elementwise add ($z = x + y$, n elements): 1 FLOP per element; 6 bytes (read 2, write 1, FP16) → $I = 1/6 \approx 0.17$. Hopelessly bandwidth-bound — runs at ~0.1% of compute peak. This is why fusion (Phase 04 Ch. 9) matters.
- Matrix multiply ($M{\times}K \cdot K{\times}N$): $2MNK$ FLOPs; $2(MK + KN + MN)$ bytes ideally. For large square matrices $I \approx N/3$ — at N=1024, ~341 FLOP/byte: compute-bound. For matrix-vector (M=1): $I \approx 1$ — brutally bandwidth-bound. Same op family, opposite regimes — batch size is destiny.
- Attention (unfused): the $QK^\top$ and $AV$ matmuls plus a softmax over an $n \times n$ matrix that must round-trip DRAM — intensity collapses, which is exactly what FlashAttention fixes (Phase 08).
Chapter 3: The Roofline Model
Plot attainable performance vs intensity (both axes log):
$$\text{Attainable FLOPS}(I) = \min(P,; B \cdot I)$$
The slanted part ($B \cdot I$) is the bandwidth roof; the flat part ($P$) the compute roof; they meet at the ridge $I^*$. Place each kernel at its measured (I, FLOPS) point:
- On/near the slanted roof → bandwidth-bound and running as fast as physics allows: stop optimizing the kernel, raise its intensity (fuse, batch, quantize the bytes).
- On the flat roof → compute-bound and efficient: lower precision or better hardware.
- Below both roofs → neither resource is saturated: launch overhead, poor tiling, serialization, low occupancy — implementation problems. This is the interesting region and where most real kernels live.
Lab 01 builds exactly this: measure device ceilings empirically (a big matmul for P, a big memcpy-like op for B), count FLOPs/bytes per layer, and produce the plot with each layer placed. Refinement to know: multiple ceilings (e.g., without tensor cores, without FP16) give a ladder of roofs; cache-aware rooflines use bytes-from-each-level.
Chapter 4: FLOP and Byte Counting for Real Models
FLOP rules of thumb (memorize):
- Linear layer: $2 \cdot d_{in} \cdot d_{out}$ per token ("multiply + add" = 2).
- Transformer forward: $\approx 2 \cdot N_{params}$ FLOPs per token (every parameter is used in one multiply-add). Attention adds $\approx 2 n^2 d$ per layer for context $n$.
- Training step: $\approx 6 \cdot N_{params}$ per token (forward + ~2× for backward).
Byte counting is subtler and regime-dependent: weights are read once per forward (per batch — they're shared across the batch, the key fact); activations scale with batch × sequence; the KV cache (Phase 02 Ch. 3) is read in full per decoded token. Counting bytes honestly per layer — including intermediate tensors that round-trip DRAM in unfused chains — is the part of Lab 01 that changes how you see models.
Chapter 5: Prefill vs Decode — The Two Regimes of LLM Inference
The most consequential roofline application in modern ML:
- Prefill (process the prompt): all prompt tokens at once → big matmuls (batch × seq rows) → high intensity → compute-bound. Measured in tokens/sec throughput; benefits from FP16/INT8 compute.
- Decode (generate token-by-token): one token's matvecs against all weights + full KV-cache read → $I \approx 1$ → bandwidth-bound. Latency per token ≈ (weight bytes + KV bytes) / bandwidth. Benefits from weight quantization (fewer bytes), GQA (smaller KV), speculative decoding (amortize reads over multiple tokens — Phase 08), and batching (share the weight read across requests — continuous batching's whole premise).
One model, two opposite bottlenecks, two disjoint optimization menus. Every "why is my LLM slow" conversation starts by asking which regime dominates the workload.
Chapter 6: How Profilers Work
Know your instruments' mechanisms, because each lies differently:
- Sampling profilers (py-spy, perf, async-profiler): interrupt periodically, record stacks. Low overhead, statistical; misses short events, great for "where does time go overall".
- Tracing/instrumenting profilers (
torch.profiler, Perfetto): record begin/end of every annotated event. Exact spans; overhead distorts very small ops. - The GPU async trap: CUDA kernels launch asynchronously — Python-side timing
measures launch, not execution.
torch.profilercorrelates GPU events properly via CUPTI; manual timing must usetorch.cuda.Eventorsynchronize()around the region. - Hardware counter tools (Nsight Compute, vendor NPU profilers): per-kernel utilization, achieved bandwidth, occupancy — the data the roofline needs. Heavyweight; use on the top-5 kernels the trace identified, not everything.
Reading a torch trace (Lab 02): find the gaps (GPU idle = launch overhead or CPU
preprocessing on the critical path), the long bars (the kernels to roofline), and the
memcpy bars (unintended host↔device transfers — a top-3 real-world finding, usually
.item()/.cpu() in the loop).
Chapter 7: Measurement Methodology — How Not to Lie to Yourself
The discipline (every number you report passes these):
- Warmup before measuring: JIT compilation, cache fill, clock ramp all pollute early iterations (cf. JVM warmup — same physics, different stack).
- Synchronize at boundaries (GPU async trap above).
- Report distributions, not means: median + p95/p99 over ≥30 iterations; latency distributions are skewed and tails are what users feel.
- Control the environment: fixed clocks where possible (
nvidia-smi -lgc), thermal steady-state for mobile (Phase 06), no competing processes, pinned versions. Record it all — an unreproducible benchmark is an anecdote. - Change one variable per experiment and keep a results table. "I changed three things and it's faster" teaches nothing and regresses later.
- Validate the workload is real: microbenchmark shapes must match deployment shapes — a kernel tuned for 4096² that deploys at batch-1 decode was tuned for the wrong regime (Chapter 5).
Chapter 8: The Transformer Performance-Bug Taxonomy
Lab 03 plants these in a working model; learn the signatures:
| Bug | Signature in profile | Fix |
|---|---|---|
Sync-in-loop (.item(), .cpu(), print of a tensor) | GPU gaps every iteration; cudaStreamSynchronize spam | move logging off the hot path; accumulate on device |
| Recomputed attention masks / RoPE tables per step | identical small kernels repeated; CPU time building tensors | precompute once, cache buffers |
| Missing KV cache (recompute full attention per token) | decode latency grows linearly with generated length | implement the cache (Phase 02 Ch. 3) |
| Unfused elementwise chains | many short bandwidth-bound kernels back-to-back | torch.compile / manual fusion |
Wrong-layout copies (contiguous() storms) | memcpy/transpose kernels between real work | fix the producing op's layout |
| FP32 where FP16/BF16 intended | 2× the expected kernel time, 2× bytes | audit dtypes at module boundaries |
| Accidental dynamic shapes | recompilation stalls (Phase 04), allocator churn | pad/bucket shapes |
| CPU dataloader starving the GPU | GPU idle, low power draw, dataloader threads hot | prefetch, pin memory, more workers |
The meta-skill: each bug is invisible in code review and obvious in a trace — measure first is not a slogan, it's the procedure.
Lab Walkthrough Guidance
Order: Lab 01 (roofline) → Lab 02 (profiling context) → Lab 03 (bug hunt) — theory instrument, then the recording instrument, then the applied hunt.
- Lab 01: measure ceilings empirically before trusting datasheets (real sustained bandwidth is 60–80% of nominal); hand-derive the elementwise and matvec intensities from Chapter 2 and confirm your tool reproduces them; then place a small transformer's layers and write one sentence per layer naming its bound.
- Lab 02: build the profiling context manager; verify it against
torch.profileron the same workload; make the GPU-sync handling explicit and test it (the async trap is the lab's real content). - Lab 03: for each planted bug — find it in the trace first, name the taxonomy row, predict the speedup from fixing, fix, measure, compare to prediction. The predict-then-measure loop is what makes this training rather than tinkering.
Success Criteria
You are ready for Phase 08 when you can, from memory:
- Derive arithmetic intensity for elementwise, square matmul, and matvec, and place each on a roofline.
- Compute a device's ridge point from its datasheet and state what it means.
- Explain prefill vs decode bounds and give two optimizations per regime that don't help the other.
- State the GPU async-timing trap and both correct measurement methods.
- Recite five rows of the bug taxonomy with signatures.
- Estimate tokens/sec for batch-1 decode of a quantized model from bandwidth alone (Phase 03 Ch. 2's formula) — and say when that estimate breaks (compute-bound prefill, small models where overhead dominates).
Interview Q&A
Q: A kernel sits well below both roofline ceilings. What are the candidate causes? Neither memory nor compute is saturated → the kernel isn't issuing enough work: launch overhead dominating (too-small grid), low occupancy (register/shared-memory limits capping resident warps), serialization (dependent small ops, host sync), divergent control flow, or non-coalesced access patterns wasting transactions (bytes requested exceed bytes used — effective bandwidth low even though DRAM is busy). Profile counters (achieved occupancy, memory efficiency) distinguish them.
Q: Quantizing weights to INT4 doubled decode speed but prefill barely moved. Explain. Decode is bandwidth-bound on weight bytes — 4× fewer bytes ≈ proportional speedup (Chapter 5). Prefill is compute-bound; INT4 weights still dequantize to FP16 for the matmul (or the INT4 compute path wasn't faster), so the compute roof didn't move. Fully expected; report regimes separately.
Q: How would you set up performance regression detection for a model in CI? Fixed device + clocks, pinned container; warmup + ≥30 measured iterations; compare median and p95 against a stored baseline with a noise-calibrated threshold (e.g., fail at >3× observed run-to-run σ); track bytes/FLOPs counters alongside time to catch "slower but also doing more work" confounds; archive traces from failures. (This is Phase 09's accuracy-regression CI with time as the metric — same statistical discipline.)
Q: Your trace shows 40% of step time in aten::copy_. Hypotheses?
Host↔device transfers on the hot path (check direction and pageable-vs-pinned), layout
conversions from contiguous() after transposes, dtype casts (FP32↔FP16 boundaries), or
gradient/optimizer copies in training. Each has a distinct source stack in the trace —
follow the stack, not the op name.
References
- Williams et al., Roofline: An Insightful Visual Performance Model for Multicore Architectures (CACM 2009)
- NVIDIA: GPU Performance Background — the math/memory/overhead trichotomy
- Kaplan et al., Scaling Laws appendix (FLOP counting conventions) — arXiv:2001.08361
- torch.profiler docs and PyTorch profiling recipe
- Nsight Compute roofline analysis
- He, Making Deep Learning Go Brrrr From First Principles — horace.io/brrr_intro.html (the best informal treatment of Chapter 5)
- Chen, LLM Inference arithmetic — kipp.ly/transformer-inference-arithmetic (worked KV/bandwidth numbers)
The Hitchhiker's Guide — Phase 07: Profiling & Performance Engineering
"Measure don't guess. Guess don't ship." — Performance engineering maxim
Section 1: The Roofline Model
The Core Idea
Every op on a GPU (or NPU) is limited by one of two things:
- Compute throughput: how fast the ALUs can multiply (FLOPS/s)
- Memory bandwidth: how fast data moves from DRAM to the ALUs (bytes/s)
The roofline model formalizes this:
$$\text{Performance (FLOPS/s)} = \min\left(\text{Peak FLOPS/s},\ \text{Peak BW (bytes/s)} \times I\right)$$
where $I$ = arithmetic intensity = FLOPS / bytes transferred.
The "roofline" is the shape formed by this function: a sloped line (memory-bound region) that flattens at the peak FLOPS ceiling (compute-bound region).
Performance
(TFLOPS/s)
│
│ __________ Peak FLOPS (A100: 312 TFLOPS FP16)
│ /
│ / ← compute-bound ops (slope = Peak BW)
│ /
│ / ← memory-bound ops (below the roofline)
│ /
└──────────────────────────────────── Arithmetic Intensity (FLOPS/byte)
L1 L2 L3 DRAM (crossover point: Peak FLOPS / Peak BW)
Crossover point (arithmetic intensity where the boundary is): $$I_{\text{crossover}} = \frac{\text{Peak FLOPS}}{\text{Peak BW}}$$
For A100: $312 \times 10^{12} / 2 \times 10^{12} = 156$ FLOPS/byte
Any op with $I < 156$ FLOPS/byte is memory-bound on A100.
Arithmetic Intensity of Common Ops
| Operation | FLOPS | Bytes Read | Bytes Written | AI (FLOPS/byte) | Bound |
|---|---|---|---|---|---|
y = x + b (elementwise add, FP32) | $N$ | $4N + 4N$ | $4N$ | $1/12 \approx 0.08$ | Memory |
y = ReLU(x) (FP32) | $N$ | $4N$ | $4N$ | $1/8 = 0.125$ | Memory |
LayerNorm (FP32) | $8N$ | $4N$ | $4N$ | $1$ | Memory |
GEMM ($M \times K \times N$, FP32) | $2MKN$ | $4(MK + KN)$ | $4MN$ | $\frac{MKN}{2(MK+KN+MN)} \approx K/4$ for large | Compute |
Attention ($n$ tokens, $d$ dim) | $4n^2d$ | $8nd + n^2$ | $4nd$ | $\frac{4n^2d}{8nd+n^2+4nd} \approx n/2$ | Compute (large $n$), Memory (small $n$) |
Conv2D ($K \times K$ kernel, $C_{in}$, $C_{out}$) | $2K^2 C_{in} C_{out} HW$ | ... | ... | $K^2 C_{in} / 2$ for large batch | Compute |
Key insight: a matmul with $K=4096$ has AI ≈ 1024 FLOPS/byte — firmly compute-bound. A matmul in decode-time attention (batch=1, $K$ is small) may be only AI = 2 FLOPS/byte — firmly memory-bound.
The Decode-Time Attention Problem
During LLM generation (decode step), batch_size=1, seq_len grows with each token. The attention operation:
- Reads K, V caches: $8 n d$ bytes (grows with sequence length)
- Compute: $4nd$ FLOPS
AI = $4nd / 8nd = 0.5$ FLOPS/byte — deeply memory-bound regardless of hardware.
This is why decode-time LLM performance scales with memory bandwidth, not FLOPS. A100 has 2 TB/s HBM bandwidth; Snapdragon 8 Gen 3 has 77 GB/s DRAM → A100 is 26× better on LLM decode, even though the TOPS ratio is much larger.
Section 2: Profiling Tools — When to Use Which
Tool Selection Guide
Question: "Why is my model slow?"
│
┌───────┴───────┐
"Which ops?" "Why is op X slow?"
│ │
torch.profiler Nsight Compute (ncu)
(Python-level) (kernel-level)
│
"Is the GPU busy?"
│
Nsight Systems (nsys)
(system-level timeline)
torch.profiler — Python-Level Profiling
import torch
from torch.profiler import profile, record_function, ProfilerActivity
model = MyModel().cuda()
x = torch.randn(32, 512).cuda()
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
record_shapes=True,
with_flops=True,
with_stack=True,
) as prof:
with record_function("model_inference"):
model(x)
# Print top ops by CUDA time
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=20))
# Export Chrome trace (open in chrome://tracing)
prof.export_chrome_trace("trace.json")
# With scheduler for training loops
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
schedule=torch.profiler.schedule(wait=2, warmup=2, active=3, repeat=1),
on_trace_ready=torch.profiler.tensorboard_trace_handler('./log/resnet18'),
) as prof:
for step, (x, y) in enumerate(dataloader):
loss = model(x).mean()
loss.backward()
optimizer.step()
prof.step() # must call step() at end of each iteration
Nsight Systems (nsys) — System Timeline
# Profile a Python training script
nsys profile \
--trace=cuda,nvtx,osrt,cudnn,cublas \
--output=profile \
python train.py
# Open the output
nsys-ui profile.nsys-rep # opens GUI
In the nsys timeline, look for:
- CPU-GPU sync points (CPU waiting for GPU to finish — gaps in GPU timeline)
- Memory transfer (H2D/D2H) — often the hidden bottleneck
- CUDA launch gaps — if GPU timeline has gaps, it's CPU-limited (not submitting kernels fast enough)
- SM occupancy — visible in NVTX rows; low occupancy = underutilized GPU
Nsight Compute (ncu) — Per-Kernel Analysis
# Profile specific kernel
ncu \
--metrics l1tex__t_bytes_pipe_lsu_mem_global_op_ld.sum,\
l1tex__t_bytes_pipe_lsu_mem_global_op_st.sum,\
sm__sass_thread_inst_executed_op_ffma_pred_on.sum \
--target-processes all \
python -c "import torch; torch.nn.functional.softmax(torch.randn(1024, 1024).cuda(), dim=-1)"
# Full report (slow but comprehensive)
ncu --set full python train.py 2>&1 | head -100
Key ncu metrics:
sm__throughput.avg.pct_of_peak_sustained_elapsed: % of peak SM utilizationl1tex__t_bytes_pipe_lsu_mem_global_op_ld.sum: bytes loaded from L1 cachedram__bytes_read.sum: bytes read from DRAM (global memory)smsp__warp_issue_stalled_long_scoreboard_per_warp_active.pct: % time stalled waiting for memory — high = memory-bound kernel
torch.utils.benchmark — Reliable Timing
import torch.utils.benchmark as benchmark
# Timer: handles warmup automatically
t = benchmark.Timer(
stmt='model(x)',
setup='import torch; model = MyModel().cuda(); x = torch.randn(32, 512).cuda()',
num_threads=1,
)
# Measure
result = t.timeit(100) # 100 repetitions
print(result)
# <torch.utils.benchmark.utils.common.Measurement object at 0x...>
# model(x)
# 2.47 ms
# 1 measurement, 100 runs , 1 thread
# Compare two implementations
results = benchmark.Compare([
benchmark.Timer(stmt='fast_model(x)', setup=fast_setup).timeit(100),
benchmark.Timer(stmt='slow_model(x)', setup=slow_setup).timeit(100),
])
results.print()
Why torch.utils.benchmark over time.time():
- Handles warmup automatically (GPU JIT compilation, caching)
- Reports statistics (mean, median, IQR)
- Handles CUDA synchronization correctly (calls
torch.cuda.synchronize()before timing) - Uses
perf_counter_nsfor high-resolution timing
Section 3: CUDA Occupancy
Occupancy = (active warps on SM) / (max theoretical warps on SM)
Occupancy is limited by the most constraining resource per SM:
- Register file: each SM has 65,536 registers; if a kernel uses 64 registers per thread and block has 256 threads: 64 × 256 = 16,384 registers per block; SM can run 4 blocks = 1024 threads = 32 warps (100% occupancy on A100 which has 64 max warps)
- Shared memory: SM has 192 KB; if kernel uses 48 KB per block: SM can run 4 blocks
- Block size: launch config must match hardware; e.g., A100 has max 1024 threads/block and max 32 blocks/SM
Calculate occupancy:
# Using PyTorch's occupancy calculator
from torch.cuda.amp import autocast
@torch.jit.script
def my_kernel(x: torch.Tensor) -> torch.Tensor:
return x.relu()
# Get theoretical occupancy
import pycuda.compiler as cuda
# Or use ncu --metrics sm__occupancy_avg...
Low occupancy is not always bad: a kernel with 25% occupancy but 100% FLOP utilization is fine. Occupancy matters when the kernel is latency-bound (lots of memory waits) — higher occupancy allows the scheduler to hide latency by switching to other warps.
Section 4: Memory Coalescing
A CUDA warp (32 threads) executes the same instruction simultaneously. When all 32 threads access a 128-byte-aligned, contiguous segment of memory, this is a coalesced access — one DRAM transaction for 32 threads.
Coalesced (efficient):
# Each thread accesses array[thread_id] — contiguous, aligned
# 32 threads → 1 memory transaction (128 bytes)
output[idx] = input[idx] # good: sequential access pattern
Non-coalesced (inefficient):
# Each thread accesses array[thread_id * stride] — strided
# 32 threads → 32 memory transactions (one per thread)
output[idx] = input[idx * 64] # bad: stride-64 access, each in different cache line
Real example — transposed access:
# Matrix stored row-major: A[row][col]
# Kernel: thread i reads column i of row j — sequential → coalesced ✅
# Kernel: thread i reads row i of column j — non-sequential → non-coalesced ❌
# Fix: use shared memory tiling
# Load a TILE×TILE block into shared memory (coalesced)
# Operate on shared memory (any pattern, fast)
Section 5: The Five Common GPU Performance Bugs
These are the bugs you will find and fix in Lab 03:
Bug 1: Missing KV Cache (2–4× slowdown in generation)
- Symptom: generation time grows O(n²) with sequence length
- Fix: cache K, V in
past_key_values; compute attention only for new tokens
Bug 2: Non-Contiguous Tensors (10–30% overhead)
- Symptom:
tensor.is_contiguous()returnsFalse; copies inserted automatically - Fix: insert
.contiguous()before ops that require contiguous memory, or refactor the transpose/slice that created the non-contiguous tensor
Bug 3: CPU-GPU Synchronization in Loop (100–1000× slowdown)
- Symptom:
tensor.item(),.numpy(),print(tensor)inside generate loop - Fix: remove synchronizations from the hot path; accumulate and print after generation completes
Bug 4: FP32 Ops in FP16 Model (2–4× slowdown)
- Symptom: in the profile, LayerNorm/Softmax runs in FP32 despite model in FP16
- Fix: use
torch.autocast(device_type='cuda', dtype=torch.float16)or explicitly cast ops to FP16; or usetorch.nn.LayerNorm(..., dtype=torch.float16)
Bug 5: Sequential Generation (Nx slowdown for batch=N)
- Symptom: calling
model.generate()N times in a loop instead of batching - Fix: pad to equal length, pass batch to
model.generate(input_ids=batch); if variable length, use continuous batching
Section 6: Interview Pitfalls
| Question | Wrong Answer | Right Answer |
|---|---|---|
| "What is the roofline model?" | "A performance graph" | "A model that predicts achievable performance as min(peak_FLOPS, peak_BW × AI) where AI = FLOPS/bytes; ops above the roofline are impossible; ops below are suboptimal; categorizes bottleneck as compute-bound vs memory-bound" |
| "How do you measure GPU memory bandwidth?" | "Check the spec sheet" | "Empirically: time a large element-wise copy (no compute); bandwidth = bytes / time. A100 spec is 2 TB/s but measured is ~1.9 TB/s. Always measure — thermal throttling, ECC, and other factors reduce practical bandwidth." |
| "What is CUDA occupancy and why does it matter?" | "How busy the GPU is" | "Ratio of active warps to maximum warps per SM. Matters for latency-hiding: a memory-bound kernel benefits from high occupancy (scheduler switches to other warps during memory waits). Low occupancy from register pressure can limit throughput even if individual threads are doing useful work." |
| "nsys vs ncu — when do you use each?" | "They're the same" | "nsys = system timeline; shows CPU-GPU interaction, gaps, synchronizations, transfers — use to find structural problems. ncu = per-kernel metrics; shows FLOP utilization, memory bandwidth, warp stalls — use once you've identified which kernel is slow." |
| "How do you benchmark a PyTorch function reliably?" | "import time; t = time.time()" | "Use torch.utils.benchmark.Timer; it handles CUDA synchronization, warmup iterations, and reports statistical summaries. Raw time.time() misses GPU async execution and JIT warm-up." |
Section 7: Resources
- Roofline Model paper — Samuel Williams et al., "Roofline: An Insightful Visual Performance Model for Floating-Point Programs and Multiprocessor Architectures" (CACM 2009)
- NVIDIA Nsight Compute documentation — https://docs.nvidia.com/nsight-compute/
- NVIDIA Nsight Systems documentation — https://docs.nvidia.com/nsight-systems/
- torch.profiler tutorial — https://pytorch.org/tutorials/recipes/recipes/profiler_recipe.html
- torch.utils.benchmark documentation — https://pytorch.org/docs/stable/benchmark_utils.html
- "CUDA C++ Best Practices Guide" — NVIDIA (memory coalescing, occupancy, shared memory)
- "GPU Performance Background" — Tim Dettmers blog: https://timdettmers.com/2020/09/07/which-gpu-for-deep-learning/
- MLPerf inference benchmarks — https://mlcommons.org/benchmarks/inference/ — real performance numbers on real hardware
👨🏻 Brother Talk — Phase 07, Off the Record
Profiling — the JD: "Experience of profiling software and optimization techniques." The phase that separates engineers who guess from engineers who measure. Be the second kind.
Brother, here is the most expensive lesson in performance engineering, and I'll give it to you free: you do not know where the time goes. You never do. Measure. Every engineer — including very senior ones — has a confident mental model of where their model spends its cycles, and that model is wrong most of the time. The op you're sure is the bottleneck is fine; the innocuous reshape you never thought about is copying gigabytes. The number of hours I've watched smart people optimize the wrong thing because they trusted their gut instead of opening a profiler is heartbreaking. This phase is about installing one reflex: before you optimize anything, profile it. Guessing is the cardinal sin.
The mental model that makes profiling make sense is the roofline (the lab builds it, and it's the most important diagram in this whole track). Every kernel is either compute-bound (limited by FLOPs/s — you're using the hardware's math units fully) or memory-bound (limited by bytes/s — you're waiting on data movement). The roofline plots arithmetic intensity (FLOPs per byte) against achievable performance, and it tells you which regime you're in — and therefore which optimizations even matter. This is the part that flips people's whole approach: if you're memory-bound, making the math faster does NOTHING. Fusing kernels, quantizing to move fewer bytes, improving data layout — those help memory-bound ops. More FLOPs/s (better matmul, more cores) helps compute-bound ops. Apply the wrong fix and you waste a week for 0%. Most transformer inference, by the way, is memory-bound at the attention and the weight-loading — which is why FlashAttention (P08) and quantization (P03) win. The roofline explains the entire rest of the track.
The bug I had to fix in the profiling lab is itself the lesson: the code used
evt.cuda_time_total, which newer PyTorch renamed to device_time_total. Profiling APIs
change, and profiling code rots faster than the code it profiles. The deeper point: don't trust
a profiler blindly either — know what its numbers mean (CPU time vs device time vs wall time;
self-time vs total-time; the warmup runs you must discard because the first call compiles/
allocates). A profiler that reports the warmup iteration as your steady-state latency will lie to
you by 10×. The discipline is: warm up, measure many iterations, take the median (not the mean —
outliers from the OS scheduler poison the mean), and know which clock you're reading.
The "find the perf bug" lab (lab 03) is the one that builds real instinct, because the bugs are
the recurring ones you'll see forever: an accidental CPU↔GPU sync (a .item() or .cpu() in
the hot loop that serializes everything), a hidden dtype conversion, a contiguous-copy from a bad
reshape, a kernel launch overhead death-by-a-thous-tiny-ops, an unfused elementwise chain. None
of these show up in the code as "slow" — they show up in the profile as a fat bar where you
didn't expect one. Train your eye to spot them. The CPU↔device sync in particular is the
single most common "why is my GPU/NPU at 30% utilization?" cause, and it's invisible until you
profile.
The thing nobody tells you about performance work: it has a scoreboard, and that's a gift. Unlike a lot of engineering, perf wins are measurable and undeniable — "I took this model from 40ms to 12ms" is a sentence with a number in it, and numbers get you promoted. But the flip side: the scoreboard is honest, so you can't fake it. You have to actually measure before and after, control for warmup and variance, and report the regime you optimized. The engineers who are trusted with performance are the ones whose numbers hold up when someone re-runs them. Be rigorous and the scoreboard is your friend.
For this job specifically: profiling is bidirectional — you profile the model to find software bottlenecks, AND you profile the hardware to understand the device (P06's on-device profiling). The Senior Staff engineer connects them: "this op is memory-bound (roofline), and on the NPU it's also a CPU-fallback (coverage), so the fix is to make it an on-chip supported op." That synthesis — software profile + hardware profile → root cause — is the deliverable. A bottleneck is never just "this op is slow"; it's "this op is slow because [memory-bound / fallback / sync / launch overhead]," and the because dictates the fix.
Career truth, brother: profiling discipline is what makes all your other optimization skills actually pay off. Quantization, fusion, compiler passes — they're only worth anything if you applied them to the right bottleneck, and only profiling tells you which one that is. The engineer who measures first is faster and more credible than the one who guesses, every single time. Install the reflex here: profile, find the regime, fix the regime, re-measure. It's the meta-skill under everything.
Build the roofline analyzer. Hunt the perf bugs. Then come to P08, where we apply all this to the hardest, most important inference optimizations — FlashAttention, speculative decoding, continuous batching — the ones that make LLMs actually shippable.
— your brother 👨🏻
Lab 01 — Roofline Analyzer
Phase: 07 — Profiling & Performance Engineering | Difficulty: ⭐⭐⭐⭐☆ | Time: 2–3 hours
The roofline model is the foundational tool for diagnosing compute-bound vs memory-bound bottlenecks.
What you build
RooflineModel— computes peak FLOPS and memory bandwidth from hardware specscompute_arithmetic_intensity— FLOPS / bytes for a set of operationsclassify_bottleneck— compute-bound vs memory-bound per layerroofline_plot— text-based roofline visualization- Analysis of transformer layers: attention (memory-bound) vs linear (compute-bound)
Key concepts
| Concept | What to understand |
|---|---|
| Ridge point | peak_FLOPS / peak_BW — arithmetic intensity where compute=memory bound |
| Memory-bound ops | Elementwise (relu, add), small matmuls, LayerNorm |
| Compute-bound ops | Large matmuls, batched GEMM |
| Implication | Memory-bound → quantize/fuse; compute-bound → tile/parallelize |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Lab 02 — Profiling Context & Trace Analysis
Phase: 07 — Profiling & Performance Engineering | Difficulty: ⭐⭐⭐⭐☆ | Time: 2–3 hours
Read PyTorch Profiler traces and Chrome timeline JSON to identify performance hotspots.
What you build
- Parse
torch.profileroutput JSON (Chrome trace format) - Compute per-op total time, call count, avg time
- Identify top-N bottleneck ops
- Detect GPU idle gaps (kernel launch overhead)
- Build a summary report with actionable recommendations
Key concepts
| Concept | What to understand |
|---|---|
| Chrome trace format | {"name", "ph", "ts", "dur", "pid", "tid"} events |
torch.profiler.profile | Context manager for PyTorch profiling |
| Kernel launch overhead | CPU→GPU launch latency — batch ops to amortize |
record_function | Custom trace annotations for user-defined regions |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Lab 03 — Transformer Performance Bug Hunt
Phase: 07 — Profiling & Performance Engineering | Difficulty: ⭐⭐⭐⭐⭐ | Time: 3–4 hours
Diagnose and fix 5 common Transformer performance anti-patterns seen in production code.
What you build
Identify and fix these anti-patterns in a buggy transformer implementation:
- Incorrect attention mask causing O(T²) work even for padding tokens
- Missing
.contiguous()before reshape causing unnecessary copies - CPU-GPU sync via
.item()inside the forward loop - Redundant recomputation of
sqrt(d_k)on every call - Inefficient KV projection using separate linear layers instead of one
Key concepts
| Concept | What to understand |
|---|---|
| Memory contiguity | Non-contiguous tensors require copy before CUDA kernels |
.item() sync | Blocks GPU pipeline — never call in inference loop |
| Flash-attention mask | Causal mask should be precomputed and registered as buffer |
| GEMM batching | One [d, 3*d_k] projection is faster than three [d, d_k] projections |
Files
| File | Purpose |
|---|---|
lab.py | Buggy transformer implementation with TODO: fix markers |
solution.py | Fixed implementation with explanations |
test_lab.py | pytest suite checking correctness and performance |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Phase 08 — Inference Optimization at Depth
Difficulty: ⭐⭐⭐⭐⭐
Estimated Time: 3 weeks (70–90 hours)
Roles Supported: Inference engineers, model deployment engineers — FlashAttention and speculative decoding are now baseline expectations for Senior Staff
Why This Phase Exists
FlashAttention is the single most important algorithm to have implemented from scratch for this role. Every Senior Staff inference engineer is expected to understand it. Not just "it's IO-efficient" — but: what is the tiling strategy, how does the softmax normalization work across tiles, what are the backward pass gradients, and how does it extend to multi-query attention.
This phase covers the three most impactful inference optimizations in the last three years: FlashAttention (reduces attention memory from O(n²) to O(n)), speculative decoding (increases generation throughput 2–4×), and continuous batching (increases server-side utilization 5–10×). If you have implemented all three from scratch, you can speak to any inference optimization discussion at depth.
Concepts
- Standard attention memory: stores full $n \times n$ attention matrix — $O(n^2)$ memory, becomes prohibitive at $n > 8192$
- FlashAttention v1/v2/v3: tiled SRAM-efficient attention; reads/writes HBM $O(n)$ times instead of $O(n^2)$; no intermediate attention matrix materialized
- Online softmax: compute softmax in tiles without seeing all logits first; requires tracking running max and normalizing factor per tile; enables tiling
- FlashAttention tiling: split Q into blocks of $B_r$ rows; for each Q block, iterate over all K/V blocks of $B_c$ columns; update running softmax state; final output is accumulated correctly
- FlashAttention backward: requires storing only the output $O$ and logsumexp $L$ (not the attention matrix) — enables $O(n)$ memory backward
- Causal masking in FlashAttention: only certain tiles are "causal" (span the diagonal); others are fully masked or fully unmasked — optimized separately
- KV cache: store K and V for all previously generated tokens; avoid recomputing; grows linearly with sequence length; enables $O(1)$ compute per decode step
- Paged attention (vLLM): KV cache stored in non-contiguous "pages" (blocks of tokens); allows dynamic allocation without pre-allocating max-sequence-length contiguous memory; enables higher server utilization
- Speculative decoding: use a small draft model to generate k tokens; verify all k tokens with the large target model in one forward pass; accept tokens that match target model's distribution
- Acceptance rate: fraction of draft tokens accepted by verifier; acceptance rate $\alpha$ → expected tokens per target model call = $(1 - \alpha^{k+1}) / (1 - \alpha)$; higher $\alpha$ → more speedup
- Continuous batching: serve multiple requests simultaneously; when one request finishes, immediately insert a new one without waiting for the entire batch to complete; critical for high-throughput inference servers
- Prefix caching: cache KV states for common prompt prefixes; next request with the same prefix reuses cached KVs; important for chat history, system prompts
- Chunked prefill: split long prefill (prompt processing) across multiple decode steps; prevents long prefills from blocking short decode steps in a batched server
Labs
Lab 01 — FlashAttention v2 from Scratch in Triton
| Field | Value |
|---|---|
| Goal | Implement FlashAttention v2 forward (and optionally backward) in Triton; verify numerical correctness against standard attention within 1e-4; demonstrate memory savings and speedup at $n=4096, 8192$ |
| Concepts | Tiled attention, online softmax with running max/normalizer, SRAM vs DRAM bandwidth, Triton tile sizes, causal masking optimization, tl.dot, tl.load/store with masks |
| Steps | 1. Implement standard attention as reference; 2. Implement flash_attn_forward_kernel in Triton: tile Q in blocks of BLOCK_Q; iterate over K/V blocks; use online softmax to accumulate output; 3. Validate output matches standard attention within 1e-4; 4. Implement causal masking (skip future tiles entirely); 5. Benchmark: standard attention vs FlashAttention at seq_len=[512, 1024, 2048, 4096, 8192]; plot memory and latency; 6. (Bonus) implement backward pass storing only O and L |
| Stack | Triton 2.3+, PyTorch 2.3+, CUDA GPU (≥16 GB VRAM for seq_len=8192 comparison) |
| Datasets | Synthetic |
| Output | flash_attn_forward.py Triton kernel; numerical correctness report; memory comparison plot (quadratic vs linear); speedup table |
| How to Test | pytest test_lab.py — checks: output matches standard attention within 1e-4 for 5 test cases, memory usage is O(n) not O(n²), causal version passes autoregressive generation test, speedup > 1.5× at seq_len=4096 |
| Talking Points | "Derive the online softmax update formula." (you can write the 3-line recurrence: $m_i = \max(m_{i-1}, \max(q_i k^T))$, $l_i = e^{m_{i-1}-m_i} l_{i-1} + \sum e^{q_i k^T - m_i}$, $O_i = \text{diag}(e^{m_{i-1}-m_i}) O_{i-1} + e^{q_i k^T - m_i} v^T$) |
| Resume Bullet | Implemented FlashAttention v2 from scratch in Triton; validated within 1e-4 of standard attention; demonstrated 3.2× memory reduction and 2.1× speedup at seq_len=4096 |
| Extensions | Add multi-query attention (MQA) variant; implement sliding window attention (Mistral); add ALiBi bias support; implement backward pass |
Lab 02 — Speculative Decoding Engine
| Field | Value |
|---|---|
| Goal | Implement speculative decoding from scratch: a draft model generates $k=5$ candidate tokens; the target model verifies all in one forward pass; acceptance/rejection sampling produces lossless output; benchmark throughput vs standard decoding |
| Concepts | Token acceptance sampling, rejection sampling proof (lossless = same distribution as target), acceptance rate measurement, draft model selection criteria, adaptive $k$ |
| Steps | 1. Implement SpeculativeDecoder class with draft_model and target_model fields; 2. Implement draft_k_tokens(input_ids, k) using the draft model greedily; 3. Implement verify_and_accept(draft_tokens, draft_probs, target_logits) — compare distributions and accept/reject using sampling; 4. Implement the rejection sampling correction: if token rejected at position $i$, sample corrected token from adjusted distribution; 5. Prove (in comments) that the output distribution equals target distribution; 6. Benchmark: measure acceptance rate $\alpha$ for (LLaMA-3.2-3B target, LLaMA-3.2-1B draft) on WikiText-2; measure tokens/sec vs standard greedy decoding |
| Stack | PyTorch 2.3+, transformers |
| Datasets | WikiText-2, ShareGPT (for realistic prompt distribution) |
| Output | SpeculativeDecoder class; acceptance rate vs prompt type analysis; throughput comparison table |
| How to Test | pytest test_lab.py — checks: output distribution matches target-only sampling (KL < 0.01 on 1000 samples), acceptance rate > 60% for matched draft/target family, throughput > 1.5× vs target-only |
| Talking Points | "Prove speculative decoding is lossless." — use the rejection sampling argument; "When does speculative decoding hurt?" — when acceptance rate < threshold, or when target model is not the bottleneck; "What is the optimal k?" — function of target model latency and acceptance rate |
| Resume Bullet | Implemented speculative decoding engine with 74% acceptance rate using LLaMA-3.2-1B/3B pair; achieved 2.3× throughput improvement over target-only decoding on ShareGPT prompts |
| Extensions | Implement tree-based speculative decoding (speculative tree instead of chain); add adaptive k selection; implement batch speculative decoding |
Lab 03 — Continuous Batching Engine with KV Cache Management
| Field | Value |
|---|---|
| Goal | Build a mini LLM serving engine with continuous batching, paged KV cache, and request scheduling; demonstrate higher utilization vs static batching at the same latency target |
| Concepts | Continuous batching (iteration-level scheduling), paged attention (non-contiguous KV cache pages), preemption (evict a request's KV cache to make room), FCFS vs priority scheduling, throughput vs latency trade-off |
| Steps | 1. Implement KVCacheManager with fixed-size pages (e.g., 16 tokens per page) and free-list allocation; 2. Implement Request dataclass with state (waiting/prefilling/decoding/done); 3. Implement ContinuousBatchingScheduler — at each step, pack as many decode requests as possible into a batch; 4. Implement PagedAttentionKernel — attention that handles non-contiguous KV pages via an indirection table; 5. Run simulation: 100 requests with varying prompt/output lengths; compare: static batching vs continuous batching (throughput tokens/sec at P99 latency < 500ms) |
| Stack | PyTorch 2.3+, transformers, asyncio |
| Datasets | ShareGPT request trace (simulated arrival times) |
| Output | ServingEngine class; throughput vs latency plot for static vs continuous batching; KV cache utilization over time |
| How to Test | pytest test_lab.py — checks: all requests produce correct output (same as sequential decoding), KV cache never overflows, continuous batching achieves ≥2× throughput at same P99 latency vs static |
| Talking Points | "How does paged attention handle variable-length KV caches?" — explain the page table indirection; "What is the scheduling problem in continuous batching?" — explain the TTFT (time to first token) vs TBT (time between tokens) trade-off |
| Resume Bullet | Built mini LLM serving engine with continuous batching and paged KV cache; demonstrated 3.1× throughput improvement vs static batching at equivalent P99 latency on simulated ShareGPT traffic |
| Extensions | Add chunked prefill; add speculative decoding from Lab 02; add request preemption (swap out to CPU); benchmark against vLLM baseline |
Deliverables Checklist
- FlashAttention Triton kernel with correctness proof and benchmark plots
- Speculative decoding engine with acceptance rate analysis
- Continuous batching engine with throughput comparison
-
All
test_lab.pysuites pass
Interview Relevance
- "Implement FlashAttention on a whiteboard." — you derive the online softmax update, draw the tiling diagram, and explain why no intermediate attention matrix is needed
- "How does speculative decoding change the compute pattern on an NPU?" — you explain batched verification vs sequential generation, the model size ratio requirement
- "Design a serving system for a 70B LLM on 4× A100s." — you architect tensor parallelism + continuous batching + paged attention from first principles
Warmup Guide — Inference Optimization at Depth
Zero-to-expert primer for Phase 08. Builds the three pillars of modern LLM serving from first principles: FlashAttention (online softmax + tiling), speculative decoding (rejection sampling over a draft model), and continuous batching with PagedAttention.
Table of Contents
- Chapter 1: Why Attention Is Memory-Bound
- Chapter 2: Numerically Safe Softmax
- Chapter 3: Online Softmax — The Enabling Trick
- Chapter 4: FlashAttention — Tiling the Whole Thing
- Chapter 5: Speculative Decoding — The Bandwidth Arbitrage
- Chapter 6: The Acceptance Rule — Why the Distribution Is Exact
- Chapter 7: Serving Many Requests — Static vs Continuous Batching
- Chapter 8: PagedAttention — Virtual Memory for the KV Cache
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Why Attention Is Memory-Bound
Standard attention materializes the score matrix:
- $S = QK^\top$ — an $n \times n$ matrix, written to DRAM ($O(n^2)$ bytes).
- $P = \text{softmax}(S)$ — read $n^2$, write $n^2$.
- $O = PV$ — read $n^2$ again.
FLOPs are $O(n^2 d)$, but DRAM traffic is $O(n^2)$ with a small constant of reuse — arithmetic intensity is low (Phase 07's framework), so for realistic $d$ the kernel runs on the bandwidth roof, far below compute peak. Worse, at $n = 32K$, the FP16 score matrix is $32768^2 \times 2 \approx 2$ GB per head per batch element — it doesn't even fit.
The fix is not approximation (sparse/linear attention change the math). FlashAttention computes the exact same output while never materializing $S$ — pure memory choreography. Two ideas make it possible: safe softmax (Ch. 2) and its online form (Ch. 3).
Chapter 2: Numerically Safe Softmax
$\text{softmax}(x)_i = e^{x_i} / \sum_j e^{x_j}$ overflows FP16 at $x \approx 11$ ($e^{11} > 65504$). The standard fix subtracts the max (mathematically identity — numerator and denominator share the factor $e^{-m}$):
$$m = \max_j x_j, \qquad \text{softmax}(x)_i = \frac{e^{x_i - m}}{\sum_j e^{x_j - m}}$$
Now every exponent is ≤ 0, every $e^{x_i - m} \in (0, 1]$. The cost: softmax now needs two reduction passes (max, then sum) before the normalize pass — three sweeps over data you wanted to touch once. That structure is what Chapter 3 dismantles.
Chapter 3: Online Softmax — The Enabling Trick
The question: can you compute a safe softmax in one pass, seeing elements block-by-block, never knowing the global max in advance?
Yes — keep running statistics and rescale when the max changes. Process blocks $b = 1, 2, \ldots$; maintain the running max $m$ and running denominator $\ell$:
$$m^{new} = \max(m, \tilde{m}_b), \qquad \ell^{new} = \ell \cdot e^{m - m^{new}} + \tilde{\ell}_b \cdot e^{\tilde{m}_b - m^{new}}$$
where $\tilde{m}_b, \tilde{\ell}_b$ are the block's local max and local exp-sum. The factor $e^{m - m^{new}}$ retroactively corrects everything accumulated under the old max — exactness preserved by exponent algebra, not approximation.
The same correction extends to the attention output accumulator: maintain $O = \sum p_j v_j$ un-normalized, rescale by $e^{m - m^{new}}$ whenever the max moves, divide by $\ell$ once at the very end. This is the entire mathematical content of FlashAttention; everything else is engineering.
Chapter 4: FlashAttention — Tiling the Whole Thing
The kernel (Lab 01 implements it tile-by-tile):
for each block Q_i of queries (loaded once into SRAM):
m = -inf; l = 0; O_acc = 0
for each block (K_j, V_j) of keys/values (streamed through SRAM):
S_ij = Q_i @ K_j.T / sqrt(d) # tile of scores — lives only in SRAM
(apply causal mask for the diagonal block; skip blocks above it)
update m, l, O_acc with the online-softmax rescaling (Ch. 3)
O_i = O_acc / l # normalize once
write O_i to DRAM # the ONLY n×d-sized write
Accounting: DRAM traffic falls from $O(n^2)$ to $O(n^2 d / M)$ where $M$ is SRAM size — in practice 5–10× less traffic, kernel moves toward the compute roof, and memory footprint is $O(n)$, unlocking long context. FLOPs slightly increase (the rescaling) — a deliberately losing trade on compute to win on bandwidth, the signature move of memory-bound optimization.
Backward pass: don't store $P$; recompute tiles from $Q, K$ in the backward sweep, keeping only the per-row $m, \ell$ statistics — same recompute-over-store logic as activation checkpointing. FA2 vs FA1 (worth knowing): FA2 reorders loops (parallelize over query blocks AND heads/sequence), reduces non-matmul FLOPs, and improves work partitioning between warps — ~2× over FA1 on the same math.
Chapter 5: Speculative Decoding — The Bandwidth Arbitrage
The setup (Phase 07 Ch. 5): batch-1 decode reads all weights to produce one token. Verifying $k$ tokens at once costs nearly the same wall-clock as generating one — the weight read amortizes over $k$ positions (it's a small prefill).
The arbitrage: let a small, fast draft model propose $\gamma$ tokens autoregressively (cheap — its weights are small), then run the big target model once over all $\gamma$ positions in parallel and accept the longest valid prefix. Big-model bandwidth cost per accepted token drops by the mean accepted length.
Expected speedup: with per-token acceptance rate $\alpha$ and draft length $\gamma$, the expected tokens per target-model pass is $\frac{1 - \alpha^{\gamma+1}}{1 - \alpha}$; speedup follows after subtracting draft cost. Practical: α ≈ 0.6–0.8 for a well-matched draft → 2–3×. Tuning $\gamma$ trades wasted draft work (rejections) against amortization — Lab 02 sweeps it and profiles α per domain (code drafts accept far more than creative prose).
Chapter 6: The Acceptance Rule — Why the Distribution Is Exact
The non-obvious part: the output distribution is provably identical to sampling the target model alone. For draft distribution $q$ and target $p$ at each position:
- Accept the drafted token $x$ with probability $\min(1,, p(x)/q(x))$.
- On rejection, resample from the residual distribution $\text{norm}(\max(0,, p - q))$ and stop the speculation there.
Why it works (the one-line proof sketch): P(output = x) = q(x)·min(1, p(x)/q(x)) + P(reject)·residual(x) = min(q, p) + (p − min(q, p)) = p(x). The draft can be anything — a tiny LLM, an n-gram cache, Medusa heads — correctness never depends on draft quality, only speed does. Lab 02's test validates exactly this distributional identity, which is also the production guarantee you quote to stakeholders: speculative decoding is a pure latency optimization with zero accuracy risk.
Chapter 7: Serving Many Requests — Static vs Continuous Batching
Static batching: collect $B$ requests, run them in lockstep until all finish. Two pathologies: (1) head-of-line waiting — short generations idle while the longest finishes (padding waste scales with length variance); (2) batch-formation latency — requests wait for the batch to fill.
Continuous (in-flight) batching: scheduling at iteration granularity. Every decode step: finished sequences exit, queued requests join, the step runs on whoever is active. GPU utilization stays high regardless of length variance; new requests start immediately. Costs: a scheduler (admission, preemption), per-step bookkeeping, and the memory problem — admitting a request whose KV cache will grow means you must manage fragmentation and out-of-memory preemption, which is Chapter 8's subject.
Prefill/decode interference is the remaining subtlety: a long prefill stalls every decoding request that iteration — production schedulers chunk prefills or disaggregate prefill and decode onto different workers.
Chapter 8: PagedAttention — Virtual Memory for the KV Cache
The problem: KV caches grow token-by-token to unpredictable lengths. Contiguous pre-allocation forces reserving max-length per request — vLLM measured 60–80% of KV memory wasted on reservation + fragmentation.
The solution is literally OS paging: divide KV storage into fixed-size blocks (e.g., 16 tokens each); each sequence holds a block table (logical → physical mapping); allocate blocks on demand as generation proceeds. The attention kernel gathers K/V through the block table (one indirection per tile — a few % overhead).
What the indirection buys, beyond near-zero waste:
- Prefix sharing: identical prompt prefixes (system prompts, few-shot headers) map to the same physical blocks with copy-on-write on divergence — beam search and parallel sampling share almost everything.
- Preemption: evict a sequence's blocks (swap to CPU or recompute later) under pressure, by table manipulation.
Lab 03 builds the block manager + continuous scheduler and measures utilization vs static batching — the waste numbers are the lesson.
Lab Walkthrough Guidance
Order: Lab 01 (FlashAttention) → Lab 02 (speculative) → Lab 03 (continuous batching).
- Lab 01: implement the non-tiled safe softmax attention as your reference oracle first. Then online softmax on a 1-D toy (verify the rescaling identity numerically per block). Then the tiled kernel; test against the oracle at several sizes including non-divisible tile boundaries and the causal mask's diagonal blocks (the two classic bug sites).
- Lab 02: implement the acceptance rule and validate the distribution (histogram over many samples vs direct target sampling) before measuring speed. Then sweep $\gamma$, measure α, and reproduce the expected-tokens formula empirically.
- Lab 03: block manager first (allocate/append/free with a fragmentation counter), then the iteration-level scheduler, then the comparison: static vs continuous on a synthetic workload with high length variance. Report utilization + p50/p99 latency.
Success Criteria
You are ready for Phase 09 when you can, from memory:
- Write safe softmax and the online update for $(m, \ell, O)$, and explain the rescaling factor's role.
- State FlashAttention's traffic result ($O(n^2) \to O(n^2 d/M)$) and why FLOPs go up.
- Derive the speculative acceptance rule and sketch the exactness proof.
- Compute expected accepted tokens for given $(\alpha, \gamma)$.
- Explain iteration-level scheduling and both static-batching pathologies.
- Describe the block table, prefix sharing, and what fragmentation PagedAttention eliminates.
Interview Q&A
Q: Why does FlashAttention speed things up while doing MORE FLOPs? Attention is bandwidth-bound (Ch. 1); the binding resource is DRAM traffic, not compute. Tiling + online softmax cut traffic ~$d/M$-fold at the cost of rescaling arithmetic — spending the abundant resource (FLOPs) to save the scarce one (bytes). On the roofline: the point moves right (higher intensity) along the bandwidth roof toward the ridge.
Q: Where does speculative decoding NOT help? (1) Compute-bound regimes — large batches/prefill: verification no longer rides free bandwidth; (2) poorly matched draft (low α) — wasted draft work exceeds amortization; (3) when the draft model itself competes for the same memory/compute budget (edge devices may not fit both); (4) sampling at high temperature over flat distributions — α drops because q and p diverge more after sharpening differences.
Q: Your continuous-batching server shows great throughput but terrible p99 latency. Why? Prefill interference: long prompts entering the batch stall every in-flight decode for those iterations. Fixes: chunked prefill (split long prompts across iterations), prefill/decode disaggregation, admission control on prompt length per iteration, priority scheduling. Also check preemption storms under memory pressure — evicted-and-recomputed sequences show up purely in the tail.
Q: How do FlashAttention and PagedAttention interact? They compose: FlashAttention defines the compute pattern (tiled streaming over K/V); PagedAttention defines the storage layout (blocks via table indirection). The fused kernel streams K/V tiles by walking the block table — block size is chosen to align with the kernel's tile size so the indirection happens once per tile, not per element.
References
- Dao et al., FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness (2022) — arXiv:2205.14135
- Dao, FlashAttention-2 (2023) — arXiv:2307.08691
- Milakov & Gimelshein, Online normalizer calculation for softmax (2018) — arXiv:1805.02867
- Leviathan et al., Fast Inference from Transformers via Speculative Decoding (2022) — arXiv:2211.17192
- Chen et al., Accelerating LLM Decoding with Speculative Sampling (2023) — arXiv:2302.01318
- Kwon et al., Efficient Memory Management for LLM Serving with PagedAttention (vLLM) (SOSP 2023) — arXiv:2309.06180
- Yu et al., Orca: A Distributed Serving System for Transformer-Based Generative Models (OSDI 2022) — continuous batching's origin
- vLLM documentation — read the scheduler and block-manager design docs
The Hitchhiker's Guide — Phase 08: Inference Optimization at Depth
"FlashAttention is the most important algorithmic contribution to LLM inference since the transformer itself." — Tri Dao
Section 1: FlashAttention — The Complete Derivation
Why Standard Attention is IO-Bound
Standard scaled dot-product attention:
$$\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V$$
The naive implementation materializes the $n \times n$ attention matrix $S = QK^T / \sqrt{d_k}$ in HBM (GPU DRAM), then the softmax of $S$, then the final product with $V$:
Q [n×d] → HBM
K [n×d] → HBM
V [n×d] → HBM
S [n×n] ← COMPUTE: QKᵀ → WRITE to HBM ← n² memory
P [n×n] ← READ S from HBM → softmax → WRITE to HBM ← n² memory
O [n×d] ← READ P from HBM → PV → WRITE to HBM
Total HBM reads/writes: $O(n^2 d + n^2) = O(n^2)$ — quadratic in sequence length.
At $n=8192$, $d=128$: $8192^2 \times 2$ bytes = 134 MB of intermediate attention matrices. On A100 with 2 TB/s bandwidth: $134 \text{ MB} / 2 \text{ TB/s} = 67$ µs just for memory traffic — before any compute.
FlashAttention: IO-Optimal Attention
The key insight: we don't need to store the full attention matrix. We can compute the attention output tile-by-tile, using only SRAM (on-chip memory), by tracking running statistics.
Online softmax: given the recurrence, we can compute $\text{softmax}(x_1, \ldots, x_n)$ by processing blocks:
For tile $i$ of the logit vector (size $B_c$ each):
$$m_i = \max(m_{i-1},\ \text{rowmax}(S_i))$$ $$l_i = e^{m_{i-1} - m_i} l_{i-1} + \text{rowsum}(e^{S_i - m_i})$$ $$O_i = \text{diag}(e^{m_{i-1} - m_i}) O_{i-1} + e^{S_i - m_i} V_i$$
At the end: $O = \text{diag}(l_n)^{-1} O_n$
This is correct because: $$\text{softmax}(S)V = \frac{\sum_j e^{S_j} V_j}{\sum_j e^{S_j}} = \frac{e^{-m} \sum_j e^{S_j - m} V_j}{e^{-m} \sum_j e^{S_j - m}}$$
The $e^{-m}$ cancels — we only need the accumulated numerator $O$ and denominator $l$, tracked with running maximum $m$ for numerical stability.
FlashAttention Tiling Strategy
Split $Q$ into row blocks of $B_r$ rows. For each $Q$ block:
- Load $Q_i \in \mathbb{R}^{B_r \times d}$ to SRAM (stays for entire inner loop)
- Iterate over $K, V$ blocks of $B_c$ columns:
- Load $K_j \in \mathbb{R}^{B_c \times d}$, $V_j \in \mathbb{R}^{B_c \times d}$ to SRAM
- Compute $S_{ij} = Q_i K_j^T / \sqrt{d_k} \in \mathbb{R}^{B_r \times B_c}$
- Update running $(m_i, l_i, O_i)$ using the online softmax recurrence
- Write final $O_i$ to HBM
SRAM requirement: $B_r \times d + 2 \times B_c \times d$ for $Q$, $K$, $V$ blocks. For $d=128$ and $B_r = B_c = 64$: $64 \times 128 \times 4 \times 3 = 98$ KB — fits in A100 SRAM (192 KB per SM).
HBM reads/writes: $O$ and each $K, V$ block read once = $O(nd)$ — linear in sequence length.
FlashAttention in Triton — Key Implementation Details
@triton.jit
def flash_attn_fwd_kernel(
Q_ptr, K_ptr, V_ptr, Out_ptr,
L_ptr, # logsumexp for backward: L[i] = m[i] + log(l[i])
stride_qm, stride_qk,
stride_km, stride_kk,
stride_vm, stride_vk,
stride_om, stride_ok,
n_heads, seq_len, head_dim,
scale: tl.constexpr,
BLOCK_M: tl.constexpr, # B_r: rows of Q per block
BLOCK_N: tl.constexpr, # B_c: columns of K/V per block
HEAD_DIM: tl.constexpr,
):
# Which Q block this program handles
start_m = tl.program_id(0)
off_h = tl.program_id(1) # head index
off_b = tl.program_id(2) # batch index
# Initialize accumulators
m_i = tl.full([BLOCK_M], float('-inf'), dtype=tl.float32)
l_i = tl.full([BLOCK_M], 0.0, dtype=tl.float32)
acc = tl.zeros([BLOCK_M, HEAD_DIM], dtype=tl.float32)
# Load Q block (stays in SRAM for entire inner loop)
q_offsets = ...
q = tl.load(Q_ptr + q_offsets)
# Iterate over K/V blocks
for start_n in range(0, seq_len, BLOCK_N):
k = tl.load(K_ptr + k_offsets)
v = tl.load(V_ptr + v_offsets)
# Compute attention scores
s = tl.dot(q, tl.trans(k)) * scale # [BLOCK_M, BLOCK_N]
# Online softmax update
m_new = tl.maximum(m_i, tl.max(s, axis=1))
alpha = tl.exp(m_i - m_new) # rescale factor for old accumulator
p = tl.exp(s - m_new[:, None]) # softmax numerators for this block
# Update accumulator
acc = acc * alpha[:, None] + tl.dot(p.to(tl.float16), v)
# Update running stats
l_i = l_i * alpha + tl.sum(p, axis=1)
m_i = m_new
# Finalize
acc = acc / l_i[:, None]
# Store logsumexp for backward
L = m_i + tl.log(l_i)
tl.store(L_ptr + l_offsets, L)
tl.store(Out_ptr + out_offsets, acc.to(tl.float16))
Section 2: Speculative Decoding — Mathematical Proof of Losslessness
The Setup
Let $p(x)$ = target model distribution over next token
Let $q(x)$ = draft model distribution over next token
The draft model proposes tokens $x_1, \ldots, x_k$. The target model evaluates all $k+1$ positions in one forward pass (getting $p(x | \text{prefix})$ for each position).
The Acceptance Criterion
For token $x_i$ proposed by the draft:
- Accept with probability $\min\left(1, \frac{p(x_i)}{q(x_i)}\right)$
- If rejected: sample from corrected distribution $p'(x) = \text{norm}(\max(0, p(x) - q(x)))$
Theorem: The output distribution equals $p(x)$ (the target model's distribution).
Proof: Let $A$ = event that token is accepted.
$$P(\text{output} = x) = P(A) \cdot q(x) \cdot \frac{p(x)}{q(x)} + P(\text{reject}) \cdot p'(x)$$
where $P(A) = \sum_x \min(q(x), p(x))$ and $P(\text{reject}) = 1 - P(A) = \sum_x \max(0, q(x) - p(x))$.
The correction term $p'(x) = \frac{\max(0, p(x) - q(x))}{\sum_x \max(0, p(x) - q(x))}$ ensures:
$$P(\text{output} = x) = \min(p(x), q(x)) + \frac{\max(0, p(x) - q(x))}{\sum_x \max(0, p(x)-q(x))} \cdot \sum_x \max(0, q(x) - p(x))$$
With $\sum_x \max(0, q(x)-p(x)) = \sum_x \max(0, p(x)-q(x))$ (since both $p$ and $q$ sum to 1):
$$P(\text{output} = x) = \min(p(x), q(x)) + \max(0, p(x) - q(x)) = p(x) \quad \square$$
Expected Speedup Formula
Let $\alpha$ = acceptance rate per token position (assumed constant for simplicity).
With $k$ draft tokens, the expected number of tokens accepted before first rejection: $$E[\text{tokens accepted}] = \sum_{i=1}^{k} i \cdot \alpha^{i-1}(1-\alpha) + k \cdot \alpha^k = \frac{1 - \alpha^{k+1}}{1 - \alpha}$$
Each target model forward pass verifies $k$ tokens at once (same cost as generating 1 token in standard decoding). Speedup:
$$\text{Speedup} = \frac{E[\text{tokens per target call}]}{1} = \frac{1 - \alpha^{k+1}}{1 - \alpha}$$
For $\alpha = 0.7$, $k = 5$: speedup $= (1 - 0.7^6)/(1 - 0.7) = (1 - 0.118)/0.3 = 2.94\times$
Section 3: Continuous Batching & Paged Attention
Why Static Batching Wastes GPU
Static batching: pack $B$ requests into one batch, run until the longest request finishes. If requests have lengths [10, 100, 1000, 2000], then the GPU is processing all 4 until the 2000-token request finishes — 99.5% of GPU cycles are spent waiting for the longest request.
Continuous batching (iteration-level scheduling): at each decoding step, re-schedule. When any request finishes, immediately insert a new waiting request into the batch. GPU utilization approaches 100%.
Paged Attention — KV Cache Without Waste
Standard KV cache: pre-allocate max_seq_len × d_model × 2 × num_layers memory per request. For max_len=4096 and LLaMA-7B: 4096 × 128 × 32 × 2 × 32 × 2 bytes = 1 GB per request. With 8 concurrent requests: 8 GB — used even if all requests are only 100 tokens.
Paged attention: divide the KV cache into fixed-size pages (e.g., 16 tokens per page). Each request gets a page table mapping logical sequence positions to physical pages in the cache pool. Pages are allocated on demand, freed when the request completes.
KV Cache Pool: 1000 pages × 16 tokens × 128 dim × 32 layers
↑
Page Table for Request A: [page 7, page 23, page 156, ...]
Page Table for Request B: [page 1, page 44, ...]
During attention computation, the paged attention kernel uses the page table as an indirection:
# Instead of: K_cache[seq_idx]
# Paged: K_cache[block_table[seq_idx // block_size], seq_idx % block_size]
Section 4: Interview Pitfalls
| Question | Wrong Answer | Right Answer |
|---|---|---|
| "Derive the online softmax recurrence" | (unable to) | Write the 3-line update for $(m_i, l_i, O_i)$; explain that $m$ prevents overflow, $l$ accumulates the partition function, $O$ accumulates the weighted V sum |
| "Why is FlashAttention fast?" | "It avoids computing softmax" | "It avoids materializing the $n \times n$ attention matrix in HBM; SRAM reads/writes are $O(nd)$ instead of $O(n^2)$; the compute is the same but IO is reduced by $O(n/d)$ factor" |
| "When does speculative decoding hurt?" | "Never" | "When acceptance rate $\alpha < (1 - 1/\text{speedup_threshold})$; e.g., if you need 2× speedup, you need $\alpha > 0.5$. Also when the target model is not the bottleneck (rare in production)" |
| "What is paged attention and why does vLLM use it?" | "Memory optimization" | "Non-contiguous KV cache using page tables; enables (1) no memory waste from pre-allocated max-length buffers, (2) sharing of common prefixes across requests, (3) easy eviction/swap for memory-constrained serving" |
Section 5: Resources
- FlashAttention paper — Tri Dao et al., "FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness" (NeurIPS 2022)
- FlashAttention-2 — Tri Dao, "FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning" (ICLR 2024)
- FlashAttention-3 — Jay Shah et al., "FlashAttention-3: Fast and Accurate Attention with Asynchrony and Low-precision" (2024)
- Speculative decoding — Leviathan et al., "Fast Inference from Transformers via Speculative Decoding" (ICML 2023)
- vLLM / PagedAttention — Kwon et al., "Efficient Memory Management for Large Language Model Serving with PagedAttention" (SOSP 2023)
- Continuous batching — Orca blog post — https://www.usenix.org/conference/osdi22/presentation/yu
- Triton documentation — https://triton-lang.org/main/index.html
- Dao-AILab/flash-attention GitHub — read the Triton reference implementation
👨🏻 Brother Talk — Phase 08, Off the Record
Inference optimization — FlashAttention, speculative decoding, continuous batching. The techniques that turn "the model works" into "the model ships." Where the roofline pays off.
Brother, this is the phase where everything from P07 (the roofline) turns into concrete wins, and where you learn the optimizations that every serious inference system uses. Here's the unifying truth that ties them together: LLM inference is memory-bound, and almost every great inference optimization is really a memory-movement optimization in disguise. Hold that lens and FlashAttention, KV-caching, quantization, and batching all reveal themselves as variations on one theme — move fewer bytes, reuse what's on-chip.
FlashAttention is the crown jewel, and the lab makes you build it, so let me give you the "aha" before you do: standard attention materializes the full N×N attention matrix in memory — which for long sequences is enormous and, crucially, memory-bound (you write the whole matrix to DRAM, then read it back for the softmax, then again for the matmul with V). FlashAttention's insight is to never materialize that matrix — tile the computation, keep tiles on-chip (SRAM), and use the online softmax trick to combine tiles without ever holding the full row. Same math, exact same output, but the memory traffic drops from O(N²) to O(N). It's not a faster matmul; it's less data movement. That's why it wins, and that's the roofline lesson made flesh. Build the online-softmax recurrence by hand — the running max and running sum that let you process the sequence in tiles — and you'll understand the single most important inference kernel of the decade.
Speculative decoding is the cleverest idea in here, and it's beautiful: autoregressive generation is sequential (token N+1 needs token N), which means you're memory-bound loading the whole model's weights for one token at a time — terrible hardware utilization. Speculative decoding uses a small, fast "draft" model to guess several tokens ahead, then the big model verifies them all in one parallel forward pass. If the draft was right (often), you got multiple tokens for the price of one big-model pass. The profound part: it's exact — the verification guarantees you get identical output to running the big model alone, just faster. No accuracy tradeoff, pure latency win. Understand the accept/reject sampling that makes it exact, because "free speedup with zero accuracy cost" sounds like a lie until you see the math.
Continuous batching is the systems-level one and it's where throughput lives. Naive ("static") batching waits for the whole batch to finish before starting the next — but sequences finish at different lengths, so you have GPUs/NPUs idling while they wait for the slowest one. Continuous batching evicts finished sequences and slots in new ones every step, keeping the hardware saturated. It's the difference between 30% and 90% utilization on a serving system. For edge it matters less (often batch=1), but for any server-side or multi-stream scenario it's the throughput unlock, and it pairs with paged-KV-cache memory management (the vLLM idea).
The thing nobody tells you: these optimizations compose, and the order and interaction matter. FlashAttention + quantization + speculative decoding together is not just additive — quantizing the draft model makes speculation cheaper, FlashAttention changes the memory profile that batching schedules around, etc. The Senior Staff engineer holds the whole inference stack in their head and reasons about how the pieces interact, not just each in isolation. And the eternal discipline (same as P04): every one of these must be numerically verified. FlashAttention must match standard attention to tolerance; speculative decoding must be exactly equal to the base model; a quantized-and-fused pipeline must hold accuracy. Fast-but- wrong is a shipped bug.
For Qualcomm specifically: the edge twist is that batch is usually 1 and the constraint is latency + power, not throughput. So FlashAttention's memory savings matter (less DRAM traffic = less power), speculative decoding's latency win matters (but the draft model costs memory you may not have on a phone), and continuous batching matters less. Knowing which of these wins on edge vs server is the judgment the role wants — "adapt the latest GenAI techniques for NPUs" means knowing that the data-center playbook doesn't copy-paste to mobile.
Career truth, brother: these are the headline skills — the ones that make a résumé pop and an interview light up, because they're the techniques behind every product everyone's heard of. But the depth is what counts: anyone can say "we use FlashAttention"; the Senior Staff engineer can derive the online softmax on a whiteboard, explain why speculative decoding is exact, and reason about how they compose on a power-constrained device. Build them from scratch here so that depth is real, not borrowed.
Build FlashAttention. Build the speculative-decoding accept/reject. Verify everything to tolerance. Then come to P09, where we make sure all this speed didn't quietly cost us accuracy — evaluation, the other half of the job title.
— your brother 👨🏻
Lab 01 — Flash Attention (Tiled)
Phase: 08 — Inference Optimization at Depth | Difficulty: ⭐⭐⭐⭐⭐ | Time: 4–5 hours
Flash Attention (Dao et al., 2022) reduces attention memory from O(N²) to O(N) using online softmax tiling.
What you build
online_softmax— running max/sum trick for numerically stable softmax without materializing the full N×N matrixflash_attention_forward— tiled attention: process Q, K, V in blocks; update running softmax statistics- Benchmark memory usage: standard O(N²) vs Flash O(N)
- Verify numerical equivalence with PyTorch reference attention
Key concepts
| Concept | What to understand |
|---|---|
| Online softmax | m_new = max(m, row_max); exp_sum update — avoids full row storage |
| SRAM tiling | Load block of K, V to fast SRAM; iterate over Q blocks |
| Memory savings | Standard: Q+K+V+attn_matrix = O(N²); Flash: only O(N) activations |
| Recomputation | Flash recomputes attention during backward to save memory |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Lab 02 — Speculative Decoding
Phase: 08 — Inference Optimization at Depth | Difficulty: ⭐⭐⭐⭐⭐ | Time: 4–5 hours
Speculative decoding (Chen et al., 2023) uses a small draft model to generate K tokens, then verifies them all in one target model pass — achieving near-lossless 2–4× throughput gains.
What you build
TinyLM— small draft language modelspeculative_step— draft K tokens, target verifies in one forward pass, accept/reject withmin(1, p_t/p_d), sample corrected distribution on rejection, add bonus tokenspeculative_generate— full generation loopcompare_distributions— total variation distance between distributions
Key concepts
| Concept | What to understand |
|---|---|
| Accept/reject | Token t accepted with prob min(1, p_target(t) / p_draft(t)) |
| Corrected distribution | On rejection: sample from max(0, p_t - p_d) / Z |
| Bonus token | When all K accepted, target generates one extra token "for free" |
| Throughput gain | With high acceptance rate α, gain ≈ K·α + 1 tokens per target call |
| Draft-target alignment | More similar models → higher α → more speedup |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Lab 03 — Continuous Batching & PagedAttention
Phase: 08 — Inference Optimization at Depth | Difficulty: ⭐⭐⭐⭐⭐ | Time: 4–5 hours
vLLM's PagedAttention + continuous batching achieves 23× higher throughput than naive batching.
What you build
KVBlock— fixed-size memory block for KV cache storagePagedKVCache— block table mapping sequences to KV blocks, LRU eviction when OOMContinuousBatcher— admit new requests mid-batch, evict finished ones each stepsimulate_serving— end-to-end serving simulation with arrival/completion tracking
Key concepts
| Concept | What to understand |
|---|---|
| KV cache fragmentation | Static allocation wastes memory — paged allocation wastes ~0% |
| Block table | Sequence → list of physical block IDs (like virtual memory paging) |
| Continuous batching | Add/remove sequences each step — no waiting for full batch |
| LRU eviction | Evict least-recently-used blocks when new sequence can't fit |
| Preemption | Suspend running sequence, swap to CPU, resume when memory frees |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Phase 09 — Model Accuracy Evaluation & Benchmarking
Difficulty: ⭐⭐⭐⭐☆
Estimated Time: 2 weeks (40–60 hours)
Roles Supported: All — the ability to set up rigorous accuracy evaluation and regression detection is what separates engineering from research
Why This Phase Exists
At Qualcomm, the word "accuracy" appears in the role title. A Senior Staff engineer in this space must be able to quantify accuracy changes with statistical rigor, set up automated regression detection, and communicate results to leadership ("our INT4 quantization scheme reduces MMLU by 0.7 ± 0.3% at 95% confidence").
The lm-evaluation-harness (EleutherAI) is the standard toolkit, but understanding its internals is required when you need to add a custom task (e.g., an industry-specific Q&A benchmark), debug unexpected results, or integrate it into CI.
This phase also addresses the Pareto frontier thinking that is essential for this role: there is no single "best" model. There is the best model given a latency budget, memory budget, or accuracy floor. Building tools to visualize and navigate this space is a core Senior Staff contribution.
Concepts
- Perplexity: $\text{PPL}(w_1 \ldots w_n) = \exp\left(-\frac{1}{n}\sum_{i=1}^n \log P(w_i | w_{1:i-1})\right)$; lower = better; measures how surprised the model is by the test corpus; sensitive to tokenization differences — always report tokenizer alongside PPL
- Few-shot evaluation: present $k$ input-output examples before the test question; measures in-context learning ability; standard benchmarks use 0-shot, 5-shot, or 25-shot
- MMLU (Massive Multitask Language Understanding): 57 subjects, 4-choice multiple choice; 14k test questions; measures broad knowledge; standard LLM eval from 2021
- HellaSwag: sentence completion requiring common-sense reasoning; 70k examples; robust to prompt sensitivity
- GSM8K: grade school math word problems; tests chain-of-thought reasoning; pass@1 vs majority voting
- ARC (AI2 Reasoning Challenge): science questions; ARC-Easy and ARC-Challenge
- WinoGrande: pronoun resolution requiring world knowledge; 44k questions
- TruthfulQA: measures factual accuracy and hallucination rate; adversarially designed to probe model weaknesses
- Calibration: a model is well-calibrated if its stated confidence matches empirical accuracy; ECE (Expected Calibration Error) = $\sum_m \frac{|B_m|}{n}|\text{acc}(B_m) - \text{conf}(B_m)|$
- McNemar's test: statistical test to determine if two models differ significantly on the same examples; more appropriate than t-test for paired accuracy comparisons
- Accuracy regression CI: automated comparison of a model's accuracy before/after a change; fails if accuracy drops by more than a threshold with statistical significance
Labs
Lab 01 — Eval Harness from Scratch
| Field | Value |
|---|---|
| Goal | Implement a model evaluation harness that supports MMLU, HellaSwag, and perplexity evaluation with pluggable task registry and 3 sampling strategies; produce JSON results compatible with the lm-evaluation-harness format |
| Concepts | Few-shot prompt formatting, log-likelihood scoring (for multiple-choice), generation scoring (for open-ended), task registry, statistical aggregation |
| Steps | 1. Implement Task base class with format_example(), get_metric(), get_doc_to_text(); 2. Implement MMLUTask and HellaSwagTask using log-likelihood scoring; 3. Implement PerplexityTask for WikiText-2; 4. Implement EvalHarness runner with batched log-likelihood computation; 5. Implement 3 sampling strategies: greedy, temperature, top-p; 6. Add multi-shot support (0, 1, 5 shots) with configurable separator format; 7. Validate: compare results to official lm_eval on 3 models — must agree within 0.5% |
| Stack | PyTorch 2.3+, transformers, datasets |
| Datasets | MMLU, HellaSwag, WikiText-2 (all via HuggingFace datasets) |
| Output | eval_harness.py with Task, EvalHarness classes; results JSON matching lm_eval schema; comparison table showing agreement with official harness |
| How to Test | pytest test_lab.py — checks: MMLU format matches expected few-shot template, log-likelihood computation matches model.generate reference, results within 0.5% of official lm_eval for 3 tasks |
| Talking Points | "How does log-likelihood scoring work for multiple-choice?" — sum log P(continuation |
| Resume Bullet | Implemented LLM evaluation harness from scratch supporting MMLU/HellaSwag/PPL; validated within 0.5% of EleutherAI's lm-evaluation-harness on 3 models; used for Qualcomm NPU deployment accuracy tracking |
| Extensions | Add custom task support (plug in domain-specific Q&A); add Math (GSM8K) with chain-of-thought; add calibration (ECE) reporting |
Lab 02 — Accuracy-Latency Pareto Frontier Tool
| Field | Value |
|---|---|
| Goal | Build a ParetoAnalyzer that sweeps multiple quantization configurations for a model family, measures accuracy and latency for each, and produces interactive Pareto frontier visualizations |
| Concepts | Pareto optimality, hypervolume indicator, configuration sweep, interactive plotting (plotly), multi-objective optimization, dominated vs non-dominated solutions |
| Steps | 1. Define configuration space: model_size × quant_method × bits × group_size (e.g., 30 configurations); 2. For each config: run Lab 03's quantization + Lab 01's eval; record MMLU %, PPL, latency (ms), memory (GB); 3. Implement is_pareto_dominant(a, b): a dominates b if a is better in all objectives; 4. Implement compute_pareto_front(configs): filter to non-dominated solutions; 5. Implement plot_pareto_2d(x_metric, y_metric) with plotly (interactive hover); 6. Add "recommendation engine": given constraints (max_latency=50ms, max_memory=4GB), return best-accuracy config |
| Stack | PyTorch 2.3+, plotly, pandas |
| Datasets | MMLU, WikiText-2 (for quick accuracy proxies) |
| Output | Interactive HTML Pareto plots (MMLU vs latency, PPL vs memory); recommendation engine; comparison table for 30 configs; best-config selection for 3 different constraint scenarios |
| How to Test | pytest test_lab.py — checks: Pareto front is a valid non-dominated set, dominance relation is transitive and irreflexive, recommendation satisfies constraints, plotly HTML is generated |
| Talking Points | "How do you present a quantization trade-off to a product team?" — Pareto frontier visualization; "What is the hypervolume indicator and why is it useful?" — aggregates multi-objective Pareto quality into a scalar |
| Resume Bullet | Built accuracy-latency Pareto analysis tool; swept 30 quantization configs for LLaMA-3.2-1B; produced interactive Pareto visualizations enabling product team to select optimal deployment config within latency/memory constraints |
| Extensions | Add energy consumption as a third axis; add per-task accuracy (not just aggregate); implement automated recommendation reporting |
Lab 03 — Automated Accuracy Regression Pipeline
| Field | Value |
|---|---|
| Goal | Build a CI-ready accuracy regression system that: (1) runs eval harness on a model before and after a change, (2) applies McNemar's test for statistical significance, (3) fails the build if accuracy drops beyond threshold with confidence |
| Concepts | McNemar's test, multiple testing correction (Bonferroni), accuracy threshold policy, CI integration (GitHub Actions YAML), baseline model checkpointing, regression report format |
| Steps | 1. Implement AccuracyBaseline that stores per-example results for a reference model; 2. Implement AccuracyRegressor that compares new results to baseline using McNemar's test; 3. Implement RegressionPolicy with configurable thresholds (e.g., fail if MMLU drops >0.5% with p<0.05); 4. Implement RegressionReport.to_markdown() with per-task analysis; 5. Write GitHub Actions YAML that runs the regression check on every PR; 6. Test: inject a bug that degrades MMLU by 2% — verify pipeline detects and blocks |
| Stack | PyTorch 2.3+, scipy (McNemar's test), PyYAML, GitHub Actions |
| Datasets | MMLU subset (200 examples per task for fast CI run ~5 min) |
| Output | regression_pipeline.py; .github/workflows/accuracy-ci.yml; sample regression report in Markdown; demo: deliberately broken model caught by CI |
| How to Test | pytest test_lab.py — checks: McNemar's test fires on 2% degradation (p<0.05), passes on 0.1% variation (p>0.05), CI YAML is valid, report Markdown is generated |
| Talking Points | "Why use McNemar's test instead of a t-test for accuracy?" — McNemar is designed for paired binary outcomes; accounts for correlation between test examples; "How do you choose the regression threshold?" — based on calibration variance and business requirement |
| Resume Bullet | Built accuracy regression CI pipeline using McNemar's test; integrated into PR workflow; successfully blocked 3 optimization changes that caused statistically significant accuracy regressions (>0.5% MMLU) |
| Extensions | Add Bonferroni correction for multi-task testing; add historical trend visualization; implement fast proxy eval (100 examples) vs full eval (14k) with calibration |
Deliverables Checklist
- Custom eval harness matching lm-eval-harness within 0.5%
- Pareto frontier tool with 30-config sweep and interactive plots
- Accuracy regression CI pipeline with GitHub Actions integration
-
All
test_lab.pysuites pass
Guides in This Phase
- HITCHHIKERS-GUIDE.md — evaluation taxonomy, perplexity, MMLU, statistical testing
- DEEP-DIVE-EVALUATION-AT-SCALE.md — multi-objective optimization, McNemar's test, CI regression gates, sample-size planning (supports Labs 02–03)
Interview Relevance
- "How would you set up evaluation for a new model before shipping to NPU?" — you describe: baseline FP16 eval on standard benchmarks → post-quantization eval → Pareto analysis → regression CI
- "What statistical test would you use to compare two quantization methods?" — McNemar's test, explain why
- "MMLU dropped 0.8% after our latest optimization. Is that significant?" — you apply McNemar's test, check sample size, report with confidence interval
Warmup Guide — Model Accuracy Evaluation & Benchmarking
Zero-to-expert primer for Phase 09. Builds rigorous evaluation from "what is a metric" through perplexity, log-likelihood benchmark scoring, statistical significance, Pareto analysis, and regression CI.
Table of Contents
- Chapter 1: Why Evaluation Is an Engineering Discipline
- Chapter 2: Perplexity — Done Correctly
- Chapter 3: Scoring Multiple-Choice Benchmarks
- Chapter 4: The Benchmark Landscape and Its Failure Modes
- Chapter 5: Sampling Variance — Why 0.8% Might Be Nothing
- Chapter 6: McNemar's Test — Paired Model Comparison
- Chapter 7: Multi-Objective Selection — Pareto Thinking
- Chapter 8: Accuracy Regression CI
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Why Evaluation Is an Engineering Discipline
The role context: "accuracy" is in the job title because every optimization in Phases 03–08 trades accuracy for performance, and someone must quantify the trade with enough rigor to ship against. The deliverable sentence of this entire phase is:
"INT4-GPTQ reduces MMLU by 0.7 ± 0.3 points at 95% confidence; latency improves 3.4×; recommendation: ship for tier-2 devices, hold tier-1 pending QAT."
Every clause requires machinery: a harness that computes the metric identically across model variants (Lab 01), statistics that separate signal from sampling noise (Lab 03, Ch. 5–6), and a decision framework when objectives conflict (Lab 02, Ch. 7).
The cardinal rule: evaluation code is production code. An off-by-one in prompt formatting or a tokenizer mismatch produces numbers that are precisely wrong — they look authoritative and misdirect a quarter's work. Treat eval bugs as P0s.
Chapter 2: Perplexity — Done Correctly
From zero: a language model assigns probability to text. Good models assign high probability to real text. Perplexity is the geometric-mean inverse probability:
$$\text{PPL} = \exp!\left(-\frac{1}{N}\sum_{i=1}^{N} \log p(w_i \mid w_{<i})\right)$$
Intuition: PPL = the effective branching factor — "the model is as uncertain as if choosing uniformly among PPL tokens." Lower is better; FP16 LLaMA-class models score ~5–6 on WikiText-2.
The details that make numbers comparable (where most public numbers go wrong):
- Tokenizer-dependent: PPL is per-token; different vocabularies make numbers incomparable across model families. Compare within a tokenizer, or normalize per-byte/ per-word.
- Context construction: documents longer than the context window are evaluated with a sliding window with stride — score only the tokens whose full context is present; scoring with truncated context inflates PPL. Stride choice changes the number; report it.
- What it's for: PPL is exquisitely sensitive to distribution-level damage — it's the best cheap canary for quantization harm (Phase 03 uses it as the fast gate). It is not a capability measure: two models 0.2 PPL apart can differ wildly on reasoning.
Chapter 3: Scoring Multiple-Choice Benchmarks
How does a generative model answer multiple choice? Not by generating "B". The standard (lm-evaluation-harness) method is log-likelihood scoring:
- Format the question (+ few-shot examples) as a prompt.
- For each choice $c_k$: compute $\log p(c_k \mid \text{prompt})$ — sum of token log-probs of the continuation only (one forward pass per choice, no sampling).
- Predict $\arg\max_k$.
Normalization subtlety: longer continuations accumulate more negative log-prob —
raw sum biases toward short answers. Variants: per-token normalization
(acc_norm divides by length), per-byte normalization (robust across tokenizers).
Which variant a leaderboard used changes scores by points — when reproducing a number,
match the variant exactly.
Few-shot mechanics: the k examples, their order, the separator strings, and even whitespace measurably move scores (prompt sensitivity). The harness's job (Lab 01) is to fix every one of these degrees of freedom in code so that two runs differ only in the model. Validating your harness against the official one within 0.5% — Lab 01's acceptance bar — is what "the numbers are right" means.
Chapter 4: The Benchmark Landscape and Its Failure Modes
What each standard benchmark measures, and how it breaks:
| Benchmark | Measures | Failure mode to know |
|---|---|---|
| MMLU (57 subjects, 4-choice) | breadth of knowledge | answer-position bias; formatting sensitivity; now partially saturated/contaminated |
| HellaSwag | commonsense completion | adversarial filtering artifacts learnable by big models |
| GSM8K | multi-step arithmetic reasoning | scored by final-answer extraction — parsing bugs masquerade as reasoning deltas; CoT vs direct changes everything |
| ARC-Challenge | grade-school science | small (1.2K) → high variance (Ch. 5) |
| WinoGrande | coreference/world knowledge | near-duplicate train items in pretraining data |
| TruthfulQA | resistance to misconceptions | MC variant rewards calibrated hedging, not truth per se |
Contamination is the field-level failure mode: benchmark text leaks into pretraining data and the score measures memory, not capability. For the optimization-engineering use case (comparing a model to its own quantized variant) contamination cancels out — one of the few places our job is statistically easier than the research job.
Chapter 5: Sampling Variance — Why 0.8% Might Be Nothing
A benchmark score is an estimate from $n$ sampled questions. For accuracy $p$ on $n$ items, the standard error is $\sqrt{p(1-p)/n}$:
- MMLU full (~14K): SE ≈ 0.4% → 95% CI ≈ ±0.8%
- ARC-Challenge (1.2K): SE ≈ 1.4% → 95% CI ≈ ±2.8%
- A 200-item CI subset: SE ≈ 3.5% → a "2% regression" is pure noise
The naive comparison error: treating two scores as independent and requiring CIs not to overlap. Both models answered the same questions — the comparison is paired, and pairing removes the shared per-question difficulty variance. Paired tests (Ch. 6) detect differences several times smaller than independent CIs suggest. This is the statistical fact that makes small-subset CI gating (Ch. 8) viable at all.
Chapter 6: McNemar's Test — Paired Model Comparison
For paired binary outcomes, only the discordant questions carry information:
| B correct | B wrong | |
|---|---|---|
| A correct | (agree — no info) | $b$ |
| A wrong | $c$ | (agree — no info) |
Under $H_0$ (equal accuracy), each discordant item is a fair coin between $b$ and $c$. Test statistic (with continuity correction):
$$\chi^2 = \frac{(|b - c| - 1)^2}{b + c} \sim \chi^2_1 \quad\text{(use the exact binomial when } b + c < 25\text{)}$$
Worked example: 1000 questions; A correct on 720, B on 705. Discordant: $b = 45$ (A-only), $c = 30$ (B-only). $\chi^2 = (15-1)^2/75 = 2.61 \to p \approx 0.106$ — a 1.5- point gap on 1000 items is not significant. The same gap with $b=90, c=60$ would be. This calibration — gap size vs discordant counts — is what Lab 03 automates.
Multiple testing: gating on 10 tasks at α=0.05 each fires falsely ~40% of the time. Bonferroni (α/10) or, better, a hierarchical policy: gate on the aggregate, flag on per-task.
Chapter 7: Multi-Objective Selection — Pareto Thinking
After Phases 03–08 you hold dozens of (model × quant × config) variants with (accuracy, latency, memory, energy) each. There is no "best" — there is a Pareto front.
- Dominance: A dominates B if A is no worse on every objective and strictly better on at least one. Dominated configs are never the right choice; discard them.
- The front: the non-dominated set — the menu of rational tradeoffs. Everything Lab 02 computes flows from the dominance predicate (and its tests: irreflexive, transitive, antisymmetric).
- Choosing from the front requires preferences, made explicit: constraint satisfaction ("max accuracy subject to ≤50 ms and ≤4 GB" — the deployment-tier framing), scalarization (weighted score — sensitive to normalization; document it), or the knee point (closest to the normalized utopian point — the "best default" heuristic when stakeholders won't give you weights).
- Communication: the Pareto plot is the artifact that moves product decisions — engineers argue configs; a frontier chart lets a PM pick a point and own the tradeoff.
Chapter 8: Accuracy Regression CI
The capstone pattern (Lab 03, reused in Phase 11): make accuracy a gated property of the codebase, like tests.
Design:
- Baseline storage: per-example results (not just the aggregate!) for the reference model — required for pairing (Ch. 6); content-address it by model+data+harness version.
- Fast subset: a fixed, stratified ~200–500-item subset for PR gates (~minutes); the full suite runs nightly. Fixed means fixed — re-sampling per run adds variance.
- Policy: fail if (drop > threshold) AND (McNemar p < α) on the aggregate; warn on per-task flags. Threshold from Ch. 5's SE math, not vibes.
- Report: a markdown table per PR — aggregate delta with CI, per-task deltas, discordant counts, links to diffing examples. Humans override with recorded rationale.
- The injection test: deliberately ship a known-bad change (e.g., a 2% degradation) and verify the gate fires — a regression gate that has never caught anything is untested code.
Failure modes to design against: baseline drift (harness change invalidates stored results — version everything), flaky gates eroding trust (calibrate α to observed noise), and silent eval-harness bugs (golden-output tests on the harness itself).
Lab Walkthrough Guidance
Order: Lab 01 (harness) → Lab 02 (Pareto) → Lab 03 (regression CI).
- Lab 01: build
Taskformatting and unit-test the prompt strings (golden outputs); implement log-likelihood scoring and verify on a hand-checkable toy (2-token continuations you can compute manually); only then add tasks; finish by reconciling with officiallm_evalnumbers — investigate any gap > 0.5% to its root cause (it's always formatting or normalization). - Lab 02: dominance predicate first, with the property tests; then the front; then knee point and weighted ranking; sanity-check the front visually on synthetic clouds.
- Lab 03: McNemar on synthetic outcomes with known answers (Ch. 6's worked example as a test case); then baseline storage; then the policy; then the injection test.
Success Criteria
You are ready for Phase 10 when you can, from memory:
- Write the PPL formula and name the three comparability traps (tokenizer, windowing, stride).
- Explain log-likelihood scoring and why length normalization variants change scores.
- Compute the SE of an accuracy on n items and the 95% CI; state why pairing beats it.
- Run McNemar by hand on a 2×2 discordant table (the worked example).
- Define Pareto dominance and defend the knee point as a default.
- Sketch the regression-CI design including the injection test and why per-example baselines are stored.
Interview Q&A
Q: MMLU dropped 0.8% after our optimization. Ship or block? Don't answer from the aggregate. Get per-example pairs, run McNemar: on full MMLU (n≈14K), 0.8% with typical discordance is likely significant (block or investigate); on a 1K subset it's likely noise. Then decompose per-subject — a flat -0.8% is benign distribution shift; -8% on math with flat elsewhere is a targeted failure (often a quantized layer that matters for arithmetic). The answer is a procedure, not a verdict.
Q: Why validate a custom harness against lm-evaluation-harness within 0.5%? Because every formatting degree of freedom (separators, few-shot order, normalization variant, BOS handling) silently moves scores by points. Agreement within sampling noise on shared tasks is the only evidence your numbers live in the same universe as published ones. The 0.5% bound ≈ the SE of the tasks compared.
Q: How do you evaluate when the optimized model is on-device and slow to query? Stratified subsets sized by Ch. 5's SE math for the precision you need; pair with the host-side reference on identical examples; use PPL on a fixed shard as the cheap canary before paying for task evals; and cache per-example results — re-evaluation after a fix should only re-run affected configs. (This is the Phase 11 capstone's architecture.)
Q: Your CI gate fires on 1 of every 15 clean PRs. Diagnose. The gate's false-positive rate is miscalibrated: α too loose for the subset size, non-fixed eval subset adding sampling variance, nondeterminism in the model run (dropout left on? unseeded sampling? GPU nondeterminism?), or baseline drift. Measure run-to-run σ on identical code, set thresholds at 3σ, fix the seed and the subset, version the baseline. A flaky gate is worse than no gate — people stop believing it.
References
- lm-evaluation-harness — read
docs/on task formats and normalization - Hendrycks et al., Measuring Massive Multitask Language Understanding (MMLU) (2020) — arXiv:2009.03300
- Zellers et al., HellaSwag (2019) — arXiv:1905.07830
- Cobbe et al., Training Verifiers to Solve Math Word Problems (GSM8K) (2021) — arXiv:2110.14168
- McNemar, Note on the sampling error of the difference between correlated proportions (1947)
- Dietterich, Approximate Statistical Tests for Comparing Supervised Classification Learning Algorithms (1998) — the paired-testing canon
- Biderman et al., Lessons from the Trenches on Reproducible Evaluation of Language Models (2024) — arXiv:2405.14782
- HuggingFace: Perplexity of fixed-length models — the sliding-window mechanics
- DEEP-DIVE-EVALUATION-AT-SCALE.md — this phase's companion deep dive
The Hitchhiker's Guide — Phase 09: Model Accuracy Evaluation & Benchmarking
Section 1: The Evaluation Taxonomy
Model evaluation has three tiers:
Tier 1 — Intrinsic metrics (measure the model directly):
- Perplexity on a held-out corpus (WikiText-2, PTB, C4)
- Loss on validation set
- Fast, requires no task data, but weakly correlated with downstream performance
Tier 2 — Task benchmarks (measure performance on standardized tasks):
- MMLU, HellaSwag, ARC, WinoGrande, GSM8K, TruthfulQA
- Industry standard; slow but well-correlated with capability; allows comparison across models
Tier 3 — Human evaluation / production metrics:
- Human preference ratings (MT-Bench, Chatbot Arena)
- A/B test on production traffic
- Ground truth for deployed models; expensive, slow, necessary for final validation
For this role (model accuracy for hardware deployment), Tier 1 and Tier 2 are the daily tools. The key insight: quantization regression is primarily detectable at Tier 1 first, then Tier 2.
Section 2: Perplexity — The Right Way to Compute It
Definition
$$\text{PPL}(w_1 \ldots w_T) = \exp\left(-\frac{1}{T} \sum_{t=1}^{T} \log P(w_t \mid w_1, \ldots, w_{t-1})\right)$$
Lower PPL = better (model is less surprised by the test text).
Stride Trick for Long Documents
A transformer has a fixed context window. For a 2048-token document evaluated with a 1024-token window, naive evaluation would split into chunks, missing cross-chunk context. The stride trick overlaps chunks:
def compute_perplexity_strided(model, tokenizer, text, stride=512, max_length=1024):
encodings = tokenizer(text, return_tensors="pt")
seq_len = encodings.input_ids.size(1)
nlls = []
prev_end_loc = 0
for begin_loc in range(0, seq_len, stride):
end_loc = min(begin_loc + max_length, seq_len)
trg_len = end_loc - prev_end_loc # tokens for which we compute NLL
input_ids = encodings.input_ids[:, begin_loc:end_loc].to(device)
target_ids = input_ids.clone()
target_ids[:, :-trg_len] = -100 # mask prefix tokens (they're context only)
with torch.no_grad():
outputs = model(input_ids, labels=target_ids)
neg_log_likelihood = outputs.loss * trg_len # unnormalized
nlls.append(neg_log_likelihood)
prev_end_loc = end_loc
if end_loc == seq_len:
break
ppl = torch.exp(torch.stack(nlls).sum() / seq_len)
return ppl.item()
PPL sensitivity to tokenization: PPL is only comparable between models using the same tokenizer. LLaMA-2 and GPT-2 tokenizers produce different token counts for the same text → different PPL denominators → incomparable values. Always report the tokenizer alongside PPL.
Section 3: MMLU — How the Benchmark Works
MMLU (Massive Multitask Language Understanding) is a 4-choice multiple-choice benchmark across 57 subjects (STEM, humanities, social sciences, professional exams).
Log-Likelihood Scoring
For each question, the model is scored on which of the 4 completions it assigns highest log-probability to:
def score_multiple_choice(model, tokenizer, context, choices):
"""
context: "Question: ... A. ... B. ... C. ... D. ... Answer:"
choices: [" A", " B", " C", " D"]
Returns: index of highest log-likelihood choice
"""
scores = []
for choice in choices:
input_text = context + choice
input_ids = tokenizer(input_text, return_tensors="pt").input_ids.to(device)
choice_ids = tokenizer(choice, return_tensors="pt").input_ids[:, 1:] # skip BOS
with torch.no_grad():
outputs = model(input_ids)
logits = outputs.logits # [1, seq_len, vocab_size]
# Score only the choice tokens
choice_start = input_ids.shape[1] - choice_ids.shape[1]
log_probs = torch.nn.functional.log_softmax(logits[0, choice_start-1:-1, :], dim=-1)
score = log_probs.gather(1, choice_ids[0].unsqueeze(1)).sum().item()
scores.append(score)
return torch.tensor(scores).argmax().item()
Why MMLU is the Standard
- Diverse: 57 subjects reduces task-specific overfitting
- Cheap: multiple-choice = no generation, fast to evaluate
- Well-calibrated: results are stable across runs (no sampling variance)
- Established: every major model reports it → direct comparison possible
Typical Numbers
| Model | MMLU (5-shot) |
|---|---|
| LLaMA-3.2-1B | ~49% |
| LLaMA-3.2-3B | ~62% |
| LLaMA-3.1-8B | ~73% |
| LLaMA-3.1-70B | ~86% |
| GPT-4 | ~87% |
| Chance (4 choices) | 25% |
After INT8 quantization: typically -0.5 to -1.5% absolute
After INT4 quantization: typically -1 to -3% absolute
After extreme INT2: typically -5 to -15% — may be unacceptable
Section 4: Statistical Testing for Accuracy Comparisons
Why You Can't Just Compare Percentages
If model A gets 73.4% and model B gets 73.7% on MMLU (14k questions), is B significantly better?
With 14,000 examples, the standard error of an accuracy estimate is: $$\text{SE} = \sqrt{\frac{p(1-p)}{n}} = \sqrt{\frac{0.734 \times 0.266}{14000}} = 0.0037 = 0.37%$$
A difference of 0.3% is less than 1 standard error — not statistically significant.
McNemar's Test
For paired binary comparisons (both models evaluated on the same test examples), McNemar's test is the correct choice:
| Model B correct | Model B wrong | |
|---|---|---|
| Model A correct | $n_{11}$ | $n_{10}$ |
| Model A wrong | $n_{01}$ | $n_{00}$ |
The test statistic (with continuity correction): $$\chi^2 = \frac{(|n_{01} - n_{10}| - 1)^2}{n_{01} + n_{10}}$$
Under the null hypothesis that the two models perform equally, $\chi^2 \sim \chi^2(1)$. Reject null if $\chi^2 > 3.84$ ($p < 0.05$).
from scipy.stats import chi2
def mcnemar_test(results_a: list[bool], results_b: list[bool]):
n01 = sum(not a and b for a, b in zip(results_a, results_b)) # B right, A wrong
n10 = sum(a and not b for a, b in zip(results_a, results_b)) # A right, B wrong
if n01 + n10 == 0:
return 1.0 # no disagreements → identical → p=1.0
# McNemar's test with continuity correction
chi2_stat = (abs(n01 - n10) - 1) ** 2 / (n01 + n10)
p_value = 1 - chi2.cdf(chi2_stat, df=1)
return p_value
# Usage:
# results_baseline[i] = True if baseline model got example i correct
# results_new[i] = True if new model got example i correct
p = mcnemar_test(results_baseline, results_new)
print(f"McNemar p-value: {p:.4f}")
print("Significantly different" if p < 0.05 else "Not significantly different")
When to Use What Test
| Scenario | Test | Why |
|---|---|---|
| Same examples, same task, comparing models | McNemar | Paired binary |
| Different test sets, same model, comparing accuracy | Z-test for proportions | Unpaired |
| Comparing across multiple tasks | McNemar + Bonferroni correction | Multiple testing |
| Comparing ranking across tasks | Wilcoxon signed-rank | Non-parametric, multiple ordinal values |
Section 5: Building a Custom Eval Task
The lm-evaluation-harness task registry:
from lm_eval.api.task import Task
from lm_eval.api.instance import Instance
import datasets
class MyCustomTask(Task):
DATASET_PATH = "my_org/my_dataset"
DATASET_NAME = None
def has_training_docs(self): return True
def has_validation_docs(self): return True
def has_test_docs(self): return False
def training_docs(self): return self.dataset["train"]
def validation_docs(self): return self.dataset["validation"]
def doc_to_text(self, doc) -> str:
"""Format the prompt."""
return f"Question: {doc['question']}\nAnswer:"
def doc_to_target(self, doc) -> str:
"""The expected completion."""
return f" {doc['answer']}"
def construct_requests(self, doc, ctx, **kwargs):
"""Return list of request objects."""
# For multiple choice: one loglikelihood request per choice
return [
Instance(
request_type="loglikelihood",
doc=doc,
arguments=(ctx, f" {choice}"),
idx=i,
)
for i, choice in enumerate(doc["choices"])
]
def process_results(self, doc, results):
"""Given results from model, return metrics."""
gold = doc["answer_idx"]
pred = max(range(len(results)), key=lambda i: results[i][0])
return {"acc": int(pred == gold)}
def aggregation(self): return {"acc": mean}
def higher_is_better(self): return {"acc": True}
Section 6: Interview Pitfalls
| Question | Wrong Answer | Right Answer |
|---|---|---|
| "How is perplexity computed?" | "It measures how good the model is" | "PPL = exp(-1/T × Σ log P(wᵢ |
| "MMLU dropped 0.8% after INT4 quantization. Significant?" | "Probably not" | "Apply McNemar's test to the per-example results; with 14k examples and 0.8% → n01+n10 ≈ 112, need |
| "Why use McNemar's test instead of a t-test?" | "McNemar is for accuracy" | "McNemar is designed for paired binary outcomes; accounts for the correlation between two models tested on the same examples; t-test assumes independence, which doesn't hold here" |
| "How do you make eval results reproducible?" | "Set random seed" | "Also: pin model weights (checksum), pin tokenizer version, pin dataset version, use deterministic generation (greedy or fixed seed), record hardware/software environment, use the same stride for PPL" |
Section 7: Resources
- MMLU paper — Hendrycks et al., "Measuring Massive Multitask Language Understanding" (ICLR 2021)
- lm-evaluation-harness — https://github.com/EleutherAI/lm-evaluation-harness
- HellaSwag — Zellers et al., "HellaSwag: Can a Machine Really Finish Your Sentence?" (ACL 2019)
- GSM8K — Cobbe et al., "Training Verifiers to Solve Math Word Problems" (arXiv 2021)
- McNemar's test — Wikipedia has a clean derivation; also see: Dietterich, "Approximate Statistical Tests for Comparing Supervised Classification Learning Algorithms" (Neural Computation 1998)
- Open LLM Leaderboard — https://huggingface.co/spaces/HuggingFaceH4/open_llm_leaderboard — see canonical evaluation numbers
- BIG-bench — Srivastava et al., "Beyond the Imitation Game" (TMLR 2023) — harder diverse benchmark
👨🏻 Brother Talk — Phase 09, Off the Record
Evaluation — the other half of the job title. "Model Accuracy and AI Performance." You've spent eight phases making models faster; this is where you prove you didn't break them.
Brother, here's the uncomfortable truth this phase exists to fix: most of the speedups you learned to love quietly cost accuracy, and without rigorous evaluation you will ship the loss without knowing it. Quantize to INT4 — accuracy drops, maybe a little, maybe a cliff. Fuse and compile — usually fine, occasionally a numerical drift. Prune — depends entirely. The entire value of "model accuracy AND performance" as a role is that you optimize performance while holding accuracy, and the only way you know you held it is measurement you can trust. An engineer who makes models fast but can't prove they're still correct is a liability, not an asset. This phase makes you the asset.
The first reframe: a single accuracy number is a lie waiting to be believed. "94% accuracy" means nothing without: on what data, at what precision, with what variance, compared to what baseline, and which examples did it get wrong now that it got right before? The Senior Staff move is to think in distributions and deltas, not point estimates. Two models at "94%" can disagree on 8% of inputs (one fixed 4%, broke 4%). For deployment, the churn — what changed — matters as much as the aggregate. Build the eval harness (lab 01) to report not just the score but the per-example deltas, because that's where the real regressions hide.
The Pareto analyzer lab (the one I fixed — it was choking on an infinite-latency entry, which is
itself the lesson) is the core mental tool: accuracy vs latency is a frontier, not a
ranking. There is no single "best model" — there's a frontier of models where you can't get
more accuracy without more latency. The whole deployment decision is "where on the frontier does
this product want to sit?" — a watch wants the fast end, a flagship phone can afford the
accurate end. And the knee of the curve (the lab finds it) is usually the sweet spot — the
point of diminishing returns. Presenting this frontier, with the knee identified, is exactly what
a Senior Staff engineer brings to a design review to justify model selection. (And the bug — an
inf latency producing a NaN score — is the real-world reminder that your eval code must be
robust to the timeout/OOM entries that will appear in a real model table.)
The thing nobody tells you about evaluation: the hard part is making it trustworthy, not
making it run. Anyone can call model(test_set).accuracy(). The Senior Staff skill is building
eval that's reproducible (same seed, same data, same number — or you can't compare runs),
statistically honest (report variance and confidence; a 0.3% "improvement" inside the noise
band is not an improvement), and regression-proof (the CI lab — automated accuracy gates that
block a merge if a "harmless optimization" silently drops accuracy below threshold). That
regression CI is the deliverable that saves the org from itself: it's the difference between
"someone's clever quantization PR tanked accuracy and we found out from a customer" and "the CI
caught it before merge." Build it.
On statistical honesty, because it's where credibility is won or lost: know when a difference is real. Bootstrap confidence intervals, McNemar's test for paired model comparisons, enough eval samples that your error bars are tighter than the effect you're claiming. The engineer who says "this is 0.5% better, p < 0.01, here's the CI" is trusted; the one who says "I ran it once and it looked better" is not. At Senior Staff level you're often the referee of other people's accuracy claims in design reviews — and you can only referee if you know the statistics.
For this job specifically: evaluation closes the loop on every prior phase. You quantize (P03) → you eval the accuracy cost. You compile (P04) → you verify numerical equivalence. You deploy to the NPU (P06) → you eval on-device (because the device's numerics differ from the sim!). The "evaluation at scale" deep-dive matters because you're not eval-ing one model once — you're eval-ing N model variants × K quantization configs × the device-vs-sim gap, continuously. That's an infrastructure problem (the distributed-eval system in the system-design folder), and owning it is a Senior Staff platform contribution.
Career truth, brother: evaluation is underrated and that's your opportunity. Everyone wants to do the flashy optimization; few want to build the rigorous eval that proves the optimization was safe. So the person who does own accuracy — who builds the harness, the regression gates, the Pareto analysis, the statistical rigor — becomes the trusted gatekeeper, the one whose sign-off means something. That's quiet power, and it's exactly the "model accuracy" half of the title that most candidates underinvest in. Be the one who takes it seriously.
Build the harness with per-example deltas. Build the regression CI that blocks bad merges. Make your Pareto analysis robust to the inf/timeout entries. Then come to P10, where accuracy meets the edge — GenAI on-device, where the precision/memory constraints are brutal and eval is everything.
— your brother 👨🏻
Deep Dive — Evaluation at Scale (Phase 09)
"You cannot ship a model you cannot measure. You cannot measure a model you cannot compare. This phase is about comparison done right — from the mathematics of dominance to the statistics of significance."
Table of Contents
- Section 1: Why Evaluation at Scale Is Hard
- Section 2: Multi-Objective Optimization — From First Principles
- 2.1 What Is an Objective?
- 2.2 Why You Cannot Collapse Multiple Objectives Into One
- 2.3 Pareto Optimality — The Origin Story
- 2.4 Formal Definition: Pareto Dominance
- 2.5 The Pareto Front — Visualized and Intuited
- 2.6 Computing the Pareto Front
- 2.7 Vectorized Pareto Front (Production Implementation)
- 2.8 From Pareto Front to a Decision: The Knee Point
- 2.9 Constrained Selection: When Product Has Hard Requirements
- 2.10 Real-World Multi-Metric Evaluation (Qualcomm Context)
- Section 3: Statistical Regression Detection — From First Principles
- 3.1 What Is Statistical Inference?
- 3.2 The Null Hypothesis and Alternative Hypothesis
- 3.3 What Is a p-value, Really?
- 3.4 Type I and Type II Errors — The Cost of Being Wrong
- 3.5 Statistical Power — Detecting Real Differences
- 3.6 The Chi-Squared (χ²) Distribution — From Scratch
- 3.7 Why Accuracy Delta Alone Is Insufficient
- 3.8 Why Not a t-test? The Dependency Problem
- 3.9 McNemar's Test — Derivation and Intuition
- 3.10 Yates' Continuity Correction — Why and When
- 3.11 Worked Example: From Raw Numbers to Decision
- 3.12 Full Implementation with Diagnostics
- 3.13 Extension: Multi-Class and Regression Problems
- Section 4: CI/CD Integration — Automated Regression Gates
- Section 5: Sample Size Planning
- Section 6: End-to-End: Evaluating Quantized Model Variants
- Section 7: Interview Questions — Principal-Level Answers
- Section 8: Quick Setup and Tests
- References
Section 1: Why Evaluation at Scale Is Hard
1.1 The Naive Approach and Why It Fails
Imagine you train two versions of a model:
- v1: 84.6% accuracy on your test set
- v2: 83.0% accuracy on the same test set
Your instinct says: "v1 is better by 1.6 points, ship v1." But this is wrong for at least two independent reasons:
- The difference might be noise — with a finite test set, there's sampling uncertainty around every accuracy number. The true difference could be anywhere from −2% to +5%.
- Accuracy is not the only metric — v2 might be 3× faster, use half the memory, and run on a mobile chip where v1 doesn't fit. "Accuracy is best" is not the same as "v1 should ship."
Both failures are common in production ML teams. This phase gives you the tools to avoid both.
1.2 Two Compounding Problems
| Problem | Wrong answer | Right answer |
|---|---|---|
| Multiple metrics (accuracy, latency, memory, power) | Weighted sum | Pareto analysis |
| Statistical significance (is the accuracy delta real?) | Compare raw numbers | McNemar's test |
These two tools are orthogonal and complementary. A model evaluation at scale uses both.
Section 2: Multi-Objective Optimization — From First Principles
2.1 What Is an Objective?
In optimization, an objective is a function you want to maximize or minimize. When you train a neural network, you minimize a loss function — that's a single-objective optimization problem.
Real-world deployment involves multiple objectives that you care about simultaneously:
| Objective | Direction | Why you care |
|---|---|---|
| Accuracy (F1, top-1, mAP, etc.) | Maximize | Core task quality |
| Inference latency (ms per token) | Minimize | User experience, SLA |
| Peak memory (MB RAM) | Minimize | Fits on device |
| Model size (MB on disk) | Minimize | Download / OTA update cost |
| Power draw (mW) | Minimize | Battery life on mobile |
| Throughput (tokens/sec) | Maximize | Server cost efficiency |
These objectives conflict. Larger models tend to be more accurate and slower. Quantized models are faster and less accurate. You cannot simultaneously maximize accuracy and minimize latency without making tradeoffs.
2.2 Why You Cannot Collapse Multiple Objectives Into One
The obvious hack: create a single composite score.
$$S = w_1 \cdot \text{accuracy} + w_2 \cdot \text{speed_normalized} + w_3 \cdot \text{memory_efficiency}$$
This is called a weighted sum scalarization. It is tempting because it reduces a hard multi-objective problem to a single-objective one. But it has deep problems:
Problem 1 — Arbitrary weights. Where did $w_1 = 0.7$ come from? No principled justification. Different product teams would pick different weights and reach different conclusions from the same data.
Problem 2 — Scale sensitivity. If latency is measured in milliseconds (range: 50–500) and accuracy is measured as a fraction (range: 0.8–0.95), then latency numerically dominates the sum regardless of weights unless you normalize first. And normalization choices are also arbitrary.
Problem 3 — Non-convex tradeoff surfaces. The actual relationship between accuracy and latency is often non-linear and non-convex. A weighted sum can only find solutions on the convex hull of the tradeoff surface — it will miss entire regions of good solutions.
Problem 4 — Incomparable units. How do you compare 1 percentage point of accuracy against 10ms of latency? There is no universal conversion rate. Any weight encodes an implicit and arbitrary conversion.
The correct solution: do not collapse the objectives. Find all non-dominated solutions first, then apply business constraints to pick among them.
2.3 Pareto Optimality — The Origin Story
Vilfredo Pareto (1848–1923) was an Italian economist and engineer studying income distribution. He observed that 80% of Italy's land was owned by 20% of the population — the famous "Pareto principle" — but more importantly, he developed the concept of optimal resource allocation in settings where improving one person's situation necessarily worsens another's.
His key insight: in an economic system with multiple agents and multiple goods, you can often improve some allocation without making anyone worse off. Once you reach a state where no such improvement exists, you have reached a Pareto efficient state.
This concept migrated to engineering, operations research, and eventually ML: when you have N candidate solutions and M conflicting objectives, the Pareto-efficient solutions are those where you cannot improve one objective without worsening at least one other.
The mathematics generalizes completely from economics to model evaluation.
2.4 Formal Definition: Pareto Dominance
Let $\mathbf{a} = (a_1, a_2, \ldots, a_M)$ and $\mathbf{b} = (b_1, b_2, \ldots, b_M)$ be two models' scores across $M$ objectives, where all objectives are framed as "higher is better" (maximization).
Definition: Model $\mathbf{a}$ Pareto-dominates model $\mathbf{b}$ (written $\mathbf{a} \succ \mathbf{b}$) if and only if:
$$\mathbf{a} \succ \mathbf{b} \iff \left( \forall i \in {1, \ldots, M}: a_i \geq b_i \right) \land \left( \exists i \in {1, \ldots, M}: a_i > b_i \right)$$
In plain English:
- Model $\mathbf{a}$ is no worse than model $\mathbf{b}$ on every objective, AND
- Model $\mathbf{a}$ is strictly better than model $\mathbf{b}$ on at least one objective
If $\mathbf{a} \succ \mathbf{b}$, then there is no rational reason to ever pick $\mathbf{b}$ — $\mathbf{a}$ is unconditionally better.
Definition: A solution $\mathbf{a}$ is Pareto-optimal (a.k.a. non-dominated) if no other solution dominates it. The set of all Pareto-optimal solutions is the Pareto front (or Pareto frontier).
Converting minimization to maximization: Before applying Pareto analysis, convert all objectives to "higher is better":
- Latency (minimize) → throughput proxy: $1 / \text{latency_ms}$
- Memory (minimize) → memory efficiency: $1 / \text{memory_mb}$
- Power (minimize) → power efficiency: $1 / \text{power_mw}$
2.5 The Pareto Front — Visualized and Intuited
Consider 6 models scored on 2 objectives (accuracy ↑, speed ↑):
Model Accuracy Speed (tok/s) Status
───────────────────────────────────────────────────────
M1 0.92 1.0 Pareto-optimal
M2 0.88 2.5 Pareto-optimal
M3 0.85 3.0 Pareto-optimal
M4 0.88 2.0 Dominated by M2 (M2: same acc, strictly faster)
M5 0.80 1.5 Dominated by M3 (M3: more accurate AND faster)
M6 0.92 0.8 Dominated by M1 (M1: same acc, strictly faster)
Why is M4 dominated by M2?
- M2 accuracy = 0.88, M4 accuracy = 0.88 → M2 no worse (tied)
- M2 speed = 2.5, M4 speed = 2.0 → M2 strictly faster → M2 dominates M4. M4 is never the right choice.
Pareto front: {M1, M2, M3}
Accuracy
0.93 | M1 ●
0.90 |
0.88 | M4 ○ M2 ●
0.85 | M3 ●
0.80 | M5 ○
0.77 |
+─────────────────────────→ Speed
0.8 1.0 1.5 2.0 2.5 3.0
● = Pareto-optimal (on the frontier)
○ = dominated (below/left of the frontier curve)
The Pareto front traces the "efficient frontier" — the tradeoff curve between accuracy and speed. Above and to the right of the frontier is impossible (no model exists there). Below and to the left is suboptimal (some dominated model sits there). The frontier itself represents all rationally defensible choices.
When you have 3+ objectives, the Pareto front becomes a surface (or hyperplane) in M-dimensional space. The intuition is identical: no point on the frontier can be improved in all dimensions simultaneously.
2.6 Computing the Pareto Front
The naive algorithm: for each model $i$, check if any other model $j$ dominates it.
import torch
def is_pareto_optimal_naive(scores: torch.Tensor) -> torch.Tensor:
"""
scores: [N, M] — N models, M objectives, all to maximize (higher = better)
Returns: [N] bool tensor, True if model i is Pareto-optimal
Time complexity: O(N² × M)
- Outer loop: N iterations (one per model)
- Inner loop: N-1 comparisons per model
- Each comparison: M element-wise comparisons
Acceptable for N < 1000 models and M < 20 metrics.
"""
N = scores.shape[0]
is_optimal = torch.ones(N, dtype=torch.bool)
for i in range(N):
if not is_optimal[i]:
# Already known to be dominated — skip for efficiency
continue
for j in range(N):
if i == j:
continue
# Does model j dominate model i?
# j dominates i iff: j >= i on ALL metrics AND j > i on SOME metric
no_worse = (scores[j] >= scores[i]).all() # bool scalar
strictly_better = (scores[j] > scores[i]).any() # bool scalar
if no_worse and strictly_better:
is_optimal[i] = False
break # No need to check further for model i
return is_optimal
Step-by-step trace for our example:
scores = [
[0.92, 1.0], # M1
[0.88, 2.5], # M2
[0.85, 3.0], # M3
[0.88, 2.0], # M4
[0.80, 1.5], # M5
]
Check M4 (i=3):
vs M2 (j=1): scores[1] = [0.88, 2.5], scores[3] = [0.88, 2.0]
no_worse: [0.88>=0.88, 2.5>=2.0] = [True, True] → .all() = True
strictly_better: [0.88>0.88, 2.5>2.0] = [False, True] → .any() = True
→ M2 dominates M4. is_optimal[3] = False. Break.
Check M5 (i=4):
vs M3 (j=2): scores[2] = [0.85, 3.0], scores[4] = [0.80, 1.5]
no_worse: [0.85>=0.80, 3.0>=1.5] = [True, True] → True
strictly_better: [0.85>0.80, 3.0>1.5] = [True, True] → True
→ M3 dominates M5. is_optimal[4] = False. Break.
Final: is_optimal = [True, True, True, False, False]
2.7 Vectorized Pareto Front (Production Implementation)
For N=1000 models, the naive O(N²·M) loop in Python is too slow. Vectorized using broadcasting:
def pareto_front_vectorized(scores: torch.Tensor) -> torch.Tensor:
"""
Vectorized Pareto front computation using PyTorch broadcasting.
scores: [N, M] — higher is better for all M objectives
Returns: [N] bool tensor
How it works — broadcasting trick:
scores.unsqueeze(0) has shape [1, N, M] — "potential dominators" axis
scores.unsqueeze(1) has shape [N, 1, M] — "models being checked" axis
After broadcasting, both have effective shape [N, N, M]:
result[j, i, k] = scores[j, k] vs scores[i, k]
no_worse[j, i] = True iff model j >= model i on ALL metrics
strictly[j, i] = True iff model j > model i on SOME metric
dominated_by[j, i] = True iff model j dominates model i
A model i is NOT Pareto-optimal iff ANY j dominates it:
dominated_by.any(dim=0)[i] = True
"""
N = scores.shape[0]
# [N, N, M]: candidate[j] vs model[i] on each metric k
s_j = scores.unsqueeze(0) # [1, N, M] → broadcast to [N, N, M]
s_i = scores.unsqueeze(1) # [N, 1, M] → broadcast to [N, N, M]
no_worse = (s_j >= s_i).all(dim=2) # [N, N]: no_worse[j, i]
strictly = (s_j > s_i).any(dim=2) # [N, N]: strictly[j, i]
dominated_by = no_worse & strictly # [N, N]: dominated_by[j, i]
# Remove self-dominance (i dominates itself vacuously — remove those)
dominated_by.fill_diagonal_(False)
# Model i is Pareto-optimal iff no other model j dominates it
return ~dominated_by.any(dim=0) # [N]: True if NOT dominated
Memory cost: The intermediate [N, N, M] tensor uses N² × M × 4 bytes. For N=1000, M=5: 20 MB — fine. For N=10,000: 2 GB — problematic. In that case, use batched chunk comparisons.
Time complexity: O(N²·M) but fully vectorized on GPU/CPU SIMD — in practice 100–1000× faster than the Python loop.
2.8 From Pareto Front to a Decision: The Knee Point
The Pareto front gives you a set of equally valid, non-dominated models. You still need to pick one (or rank them) for deployment. How?
The Knee Point method — pick the Pareto-optimal model that is geometrically "closest to the ideal" when all objectives are normalized to [0, 1].
Intuition: The "utopian" point $(1, 1, \ldots, 1)$ represents a model that is the best possible on every metric simultaneously. It doesn't exist on the Pareto front, but the model closest to it in normalized space represents the best balanced tradeoff — you don't sacrifice too much on any single axis.
Algorithm:
def find_knee_point(pareto_scores: torch.Tensor) -> int:
"""
pareto_scores: [K, M] — scores of K Pareto-optimal models
Returns: index into pareto_scores of the knee point
Steps:
1. Normalize each objective to [0, 1] using min-max normalization
2. Compute Euclidean distance from each model to utopian point (1,...,1)
3. Return the model with minimum distance
"""
# Step 1: Normalize each objective independently
min_vals = pareto_scores.min(dim=0).values # [M]
max_vals = pareto_scores.max(dim=0).values # [M]
range_ = (max_vals - min_vals).clamp(min=1e-8) # avoid division by zero
normalized = (pareto_scores - min_vals) / range_ # [K, M], all in [0, 1]
# Step 2: Utopian point in normalized space
utopian = torch.ones(pareto_scores.shape[1]) # [M] = (1, 1, ..., 1)
# Step 3: Euclidean distance to utopian
distances = ((normalized - utopian) ** 2).sum(dim=1).sqrt() # [K]
return distances.argmin().item()
Worked example with 3 objectives:
Pareto models (raw scores):
M1: [accuracy=0.92, speed_norm=0.33, mem_efficiency=0.80]
M2: [accuracy=0.88, speed_norm=0.83, mem_efficiency=0.70]
M3: [accuracy=0.85, speed_norm=1.00, mem_efficiency=0.90]
Step 1 — Min-max normalization:
accuracy: min=0.85, max=0.92, range=0.07
speed_norm: min=0.33, max=1.00, range=0.67
mem_efficiency: min=0.70, max=0.90, range=0.20
M1_norm: [(0.92-0.85)/0.07, (0.33-0.33)/0.67, (0.80-0.70)/0.20]
= [1.000, 0.000, 0.500]
M2_norm: [(0.88-0.85)/0.07, (0.83-0.33)/0.67, (0.70-0.70)/0.20]
= [0.429, 0.746, 0.000]
M3_norm: [(0.85-0.85)/0.07, (1.00-0.33)/0.67, (0.90-0.70)/0.20]
= [0.000, 1.000, 1.000]
Step 2 — Distance to utopian (1, 1, 1):
M1: sqrt((1-1)² + (1-0)² + (1-0.5)²) = sqrt(0 + 1 + 0.25) = 1.118
M2: sqrt((1-0.429)² + (1-0.746)² + (1-0)²) = sqrt(0.326 + 0.064 + 1) = 1.148
M3: sqrt((1-0)² + (1-1)² + (1-1)²) = sqrt(1 + 0 + 0) = 1.000 ← MINIMUM
Knee point: M3 (fastest, most memory-efficient, acceptable accuracy drop)
M3 wins because it's best on 2 out of 3 objectives. Its accuracy deficit (0.85 vs 0.92) is outweighed by being optimal on speed and memory, and the normalized distance captures this balance objectively.
2.9 Constrained Selection: When Product Has Hard Requirements
Sometimes "knee point" is not right — the product team has hard constraints ("we cannot ship below 90% accuracy, period"). Apply constraints first, then choose from the feasible Pareto-optimal models:
def constrained_pareto_select(
pareto_scores: torch.Tensor,
metric_names: list[str],
constraints: dict, # e.g., {'accuracy_idx': 0, 'min_value': 0.90}
weights: dict, # e.g., {1: 0.7, 2: 0.3} — weights for unconstrained metrics
) -> int:
"""
1. Apply hard constraints (eliminate models below threshold)
2. Among feasible models, use weighted score
constraints: list of (metric_index, minimum_value) pairs
weights: dict {metric_index: weight} for weighted sum among feasible models
"""
valid_mask = torch.ones(len(pareto_scores), dtype=torch.bool)
for metric_idx, min_val in constraints.items():
valid_mask &= (pareto_scores[:, metric_idx] >= min_val)
if not valid_mask.any():
raise ValueError(
f"No Pareto-optimal model satisfies constraints: {constraints}. "
f"Consider relaxing the constraints."
)
valid_scores = pareto_scores[valid_mask] # [K', M]
# Weighted score among valid models
weighted = torch.zeros(valid_scores.shape[0])
for metric_idx, weight in weights.items():
weighted += weight * valid_scores[:, metric_idx]
local_best = weighted.argmax().item()
global_idx = valid_mask.nonzero().squeeze(1)[local_best].item()
return global_idx
2.10 Real-World Multi-Metric Evaluation (Qualcomm Context)
For on-device models deployed via SNPE / QNN / QAI Hub, the standard evaluation matrix:
| Metric | Direction | Tool |
|---|---|---|
| Accuracy (task-specific) | Maximize | Your eval harness |
| Latency (ms/inference) | Minimize | QAI Hub profiler, SNPE benchmark |
| Memory (peak RAM, MB) | Minimize | QAI Hub, runtime instrumentation |
| Power (mW) | Minimize | Qualcomm profiler |
| Model size (MB) | Minimize | File size |
| Throughput (fps) | Maximize | Derived from latency + batch size |
Convert all to "higher is better" before Pareto analysis:
scores_maximized = torch.stack([
accuracy, # already ↑
1.0 / latency_ms, # throughput proxy ↑
1.0 / memory_mb, # memory efficiency ↑
1.0 / power_mw, # power efficiency ↑
], dim=1) # [N, 4]
Important: 1/x transforms are monotone-decreasing, which means they preserve the dominance relationships. Model A with lower latency than B will have higher 1/latency than B — the Pareto analysis gives identical results.
Section 3: Statistical Regression Detection — From First Principles
3.1 What Is Statistical Inference?
When you run 1000 test examples through a model and observe 846 correct, you have measured a sample. The true accuracy of the model — what you'd observe if you ran it on all possible inputs — is unknown. Your observed 84.6% is an estimate with uncertainty.
Statistical inference is the discipline of reasoning from samples to populations under uncertainty. It answers: "Given what I measured, what can I conclude about the underlying truth?"
Every inference is probabilistic. We cannot say "v2 is definitely worse than v1." We can only say "if v1 and v2 were equally accurate (identical underlying distributions), the probability of observing a gap this large or larger is 16%." Whether 16% is low enough to act on is a human decision — but at least we know what we're deciding.
3.2 The Null Hypothesis and Alternative Hypothesis
In classical frequentist hypothesis testing, you formulate two competing hypotheses:
-
H₀ (null hypothesis): The simplest, most conservative claim. "Models v1 and v2 have the same true accuracy." The null hypothesis is what you assume to be true until the data convinces you otherwise.
-
H₁ (alternative hypothesis): The claim you're trying to gather evidence for. "Models v1 and v2 have different true accuracies" (two-sided) or "v2 is less accurate than v1" (one-sided).
The logic of hypothesis testing is falsification: you compute how likely your observed data is assuming H₀ is true. If the data is very unlikely under H₀, you reject H₀ in favor of H₁.
This is philosophically important: you never "prove" H₁. You only fail to disprove H₀ (which is different from H₀ being true).
3.3 What Is a p-value, Really?
The p-value is the probability of observing a test statistic as extreme as (or more extreme than) the one you measured, assuming the null hypothesis is true.
$$p = P(\text{statistic} \geq \text{observed} \mid H_0 \text{ is true})$$
What p-value is NOT:
- It is NOT the probability that H₀ is true
- It is NOT the probability that your result was due to chance
- It is NOT the probability that H₁ is false
What p-value IS:
- A measure of how compatible your data is with H₀
- Small p-value → data is incompatible with H₀ → evidence to reject H₀
The 0.05 threshold: By convention (Fisher, 1925), p < 0.05 is called "statistically significant." This threshold is arbitrary — it means "we would reject H₀ even if 5% of the time we're wrong." The actual threshold should reflect the cost of false alarms vs. missed regressions in your specific context.
3.4 Type I and Type II Errors — The Cost of Being Wrong
When you make a decision about H₀, there are four outcomes:
Reality
H₀ true H₁ true
Decision Reject Type I Correct!
H₀ Fail Correct! Type II
- Type I error (false positive): You reject H₀ (claim regression exists) but it doesn't. You blocked a valid model deployment. Rate = α (significance level).
- Type II error (false negative): You fail to reject H₀ (claim no regression) but one exists. You shipped a regressed model. Rate = β.
Power = 1 − β = probability of correctly detecting a real regression.
In ML deployment:
- Type I error (false alarm) → unnecessary engineering work, delayed shipping
- Type II error (missed regression) → user-facing quality drop, potential production incident
Setting α = 0.05 means you accept a 5% false alarm rate. If you require 80% power (standard), you need a large enough test set.
3.5 Statistical Power — Detecting Real Differences
Power depends on four quantities:
- Effect size (δ): The magnitude of the true difference. Smaller differences require more data.
- Sample size (N): More data = more power.
- Significance level (α): Lower α = harder to reject H₀ = less power.
- Test design: Paired tests (like McNemar's) have higher power than unpaired tests for correlated data.
You must decide on the minimum effect size you care about before collecting data. If you only care about regressions ≥ 2%, you need far fewer samples than if you care about ≥ 0.5% regressions.
3.6 The Chi-Squared (χ²) Distribution — From Scratch
The chi-squared distribution is the key mathematical object underlying McNemar's test. Understanding it from scratch is essential.
Step 1: The standard normal distribution.
If $Z \sim \mathcal{N}(0, 1)$ (standard normal), then $Z^2$ follows a chi-squared distribution with 1 degree of freedom, written $\chi^2_1$:
$$Z \sim \mathcal{N}(0,1) \implies Z^2 \sim \chi^2_1$$
Why? The PDF of $Z^2 = X$ is the transformation of the normal PDF. For $x > 0$: $$f_{X}(x) = \frac{1}{\sqrt{2\pi x}} e^{-x/2}$$
This is the chi-squared PDF with 1 degree of freedom.
Step 2: Summing squared normals.
If $Z_1, Z_2, \ldots, Z_k$ are independent standard normals, then:
$$\sum_{i=1}^k Z_i^2 \sim \chi^2_k$$
The parameter $k$ is called degrees of freedom (df). Adding more independent squared normals increases the df.
Step 3: Why this matters for hypothesis testing.
Many test statistics are constructed as normalized squared differences. When H₀ is true, those statistics follow a chi-squared distribution. We can then look up the probability of seeing a value "this extreme or larger" — that's the p-value.
For McNemar's test specifically, the test statistic measures the squared imbalance between regressions and improvements, normalized by the total number of disagreements. Under H₀ (models are equally accurate), this statistic is approximately $\chi^2_1$.
Step 4: Reading the chi-squared table.
For $\chi^2_1$ at $\alpha = 0.05$ (two-sided), the critical value is 3.84:
P(χ²₁ ≥ 3.84) = 0.05
If your computed statistic > 3.84 → p < 0.05 → significant
If your computed statistic ≤ 3.84 → p ≥ 0.05 → not significant
In Python:
from scipy.stats import chi2
critical_value = chi2.ppf(0.95, df=1) # = 3.841
p_value = 1 - chi2.cdf(my_statistic, df=1)
3.7 Why Accuracy Delta Alone Is Insufficient
Return to our example: v1 = 84.6%, v2 = 83.0% on N=500 examples.
The observed delta is -1.6%. But how uncertain is each estimate?
The standard error of a proportion estimator is: $$\text{SE}(\hat{p}) = \sqrt{\frac{\hat{p}(1-\hat{p})}{N}}$$
For v1: $\text{SE} = \sqrt{0.846 \times 0.154 / 500} = \sqrt{0.000261} \approx 0.016 = 1.6%$
So v1's accuracy estimate has a 95% CI of roughly $0.846 \pm 2 \times 0.016 = [81.4%, 87.8%]$.
The observed delta of -1.6% is exactly 1 standard error — barely visible above the noise floor. Without a proper test, you would block a valid model deployment.
But there's more: v1 and v2 were evaluated on the same test examples. This creates a correlation between their accuracy estimates. Most examples are easy for both models — both get them right. Most failures cluster on hard examples — both fail on the same ones. This correlation means the effective uncertainty of the delta is much smaller than $\sqrt{2} \times SE$ (which would assume independence).
Ignoring this correlation throws away statistical power. McNemar's test captures exactly this structure.
3.8 Why Not a t-test? The Dependency Problem
A two-sample t-test assumes the two sets of measurements are independent. But v1 and v2's per-example outcomes are not independent — they are evaluated on the same inputs, and the difficulty of each example affects both models' outcomes.
Specifically: whether example $i$ is correctly classified by v1 and by v2 are correlated random variables. A t-test ignores this correlation and uses the wrong variance formula, leading to incorrect p-values (usually too conservative — less power).
For paired binary outcomes (correct/wrong per example), McNemar's test is the correct choice. It uses exactly the information that matters: the examples where the two models disagree.
3.9 McNemar's Test — Derivation and Intuition
Setup: For N test examples, each model either gets it right (1) or wrong (0). Build a 2×2 contingency table:
Model v2 correct (1) Model v2 wrong (0)
Model v1 a (both right) b (v1 right, v2 wrong) ← REGRESSIONS
correct (1)
Model v1 c (v1 wrong, v2 right) d (both wrong) ← IMPROVEMENTS
wrong (0)
Note: a + b + c + d = N (total examples)
a + b = total correct for v1
a + c = total correct for v2
Key insight: Cells $a$ and $d$ tell you nothing about which model is better. They represent examples where both models agree — trivially consistent with either H₀ or H₁. Only cells $b$ and $c$ — the discordant pairs — carry information about relative performance.
Under H₀ (models are equally accurate):
- Among the discordant pairs, each discordant example is equally likely to be in cell $b$ (regression) or cell $c$ (improvement)
- The number of regressions $b$ follows a binomial distribution: $b \sim \text{Binomial}(b+c, 0.5)$
- By the normal approximation to the binomial: $\frac{b - (b+c)/2}{\sqrt{(b+c)/4}} \approx \mathcal{N}(0,1)$
Squaring this z-score gives a chi-squared statistic:
$$\chi^2 = \frac{(b - (b+c)/2)^2}{(b+c)/4} = \frac{(b-c)^2}{b+c}$$
Under H₀: $\chi^2 \sim \chi^2_1$ (approximately, for large $b+c$)
3.10 Yates' Continuity Correction — Why and When
The chi-squared distribution is continuous but the contingency table counts ($b$, $c$) are discrete. For small counts (b+c < 25), the approximation is poor — the p-value is underestimated, increasing Type I error (false positives).
Yates' continuity correction adjusts the discrete statistic toward the continuous distribution by subtracting 0.5 from the absolute difference:
$$\chi^2_{\text{Yates}} = \frac{(|b - c| - 1)^2}{b + c}$$
The $-1$ (not $-0.5$) comes from: $|b-c| - 1$ is the continuity-corrected version of $|b-c| - 0.5$, squared. The $-1$ is conservative — it reduces the test statistic, increasing the p-value slightly, which reduces false positives at the cost of slightly less power.
When to use it: Always when $b + c$ is small (< 25). For large $b+c$ (> 50), the correction has minimal effect. The convention is to always use it for safety.
3.11 Worked Example: From Raw Numbers to Decision
Test set: N = 1000 examples
v1 correct: 846/1000 (84.6%)
v2 correct: 830/1000 (83.0%)
Delta: -1.6%
Assume we can collect the full contingency table:
a = 780 (both correct)
b = 66 (v1 right, v2 wrong — regressions)
c = 50 (v1 wrong, v2 right — improvements)
d = 104 (both wrong)
Verify:
a + b = 780 + 66 = 846 ✓ (v1 correct)
a + c = 780 + 50 = 830 ✓ (v2 correct)
a + b + c + d = 1000 ✓
McNemar statistic (with Yates):
χ² = (|66 - 50| - 1)² / (66 + 50)
= (16 - 1)² / 116
= 225 / 116
= 1.94
p-value:
p = 1 - CDF(χ²₁, 1.94) = P(χ²₁ ≥ 1.94) = 0.164
Conclusion: p = 0.164 > 0.05. Not statistically significant. We cannot conclude v2 regressed.
Now suppose the models are more similar (agree on more examples):
a = 810, b = 36, c = 20, d = 134
χ² = (|36 - 20| - 1)² / (36 + 20)
= (15)² / 56
= 225 / 56
= 4.02
p = 1 - CDF(χ²₁, 4.02) = 0.044
Conclusion: p = 0.044 < 0.05. Statistically significant regression.
The paradox: Same raw accuracy delta (-1.6%), but opposite conclusions. How?
When models agree on more examples (a=810 vs a=780), there are fewer discordant pairs (b+c = 56 vs 116). Each discordant pair carries more information. With only 56 examples where the models disagree, observing 36 regressions vs. 20 improvements is a 16:20 imbalance that is very unlikely under H₀ (50:50 would be expected). With 116 discordant pairs, the same delta (66 vs 50) is much less extreme relative to the uncertainty.
Principal-level insight: McNemar's power scales with the number of discordant pairs ($b+c$), not the test set size $N$. For highly similar models (agree on 95% of examples), you need larger test sets to get enough discordant pairs for sufficient power.
3.12 Full Implementation with Diagnostics
import torch
from scipy import stats
import numpy as np
def mcnemar_test(
y_true: torch.Tensor, # [N] int — ground truth labels
y_pred_1: torch.Tensor, # [N] int — model 1 predictions
y_pred_2: torch.Tensor, # [N] int — model 2 predictions
alpha: float = 0.05,
) -> dict:
"""
Paired comparison of two classifiers on the same test set.
Returns dict with full diagnostics:
b: count of regressions (m1 right, m2 wrong)
c: count of improvements (m1 wrong, m2 right)
a: count of both correct
d: count of both wrong
chi2: McNemar test statistic (with Yates correction)
p_value: two-sided p-value
significant: True if p < alpha
net_delta: (c - b) / N — positive means m2 improved overall
acc_m1: m1 accuracy
acc_m2: m2 accuracy
power_note: qualitative note on statistical power
"""
correct_1 = (y_pred_1 == y_true) # [N] bool
correct_2 = (y_pred_2 == y_true) # [N] bool
a = ( correct_1 & correct_2).sum().item() # both right
b = ( correct_1 & ~correct_2).sum().item() # m1 right, m2 wrong (regression)
c = (~correct_1 & correct_2).sum().item() # m1 wrong, m2 right (improvement)
d = (~correct_1 & ~correct_2).sum().item() # both wrong
N = len(y_true)
assert a + b + c + d == N, "Contingency table does not sum to N"
if b + c == 0:
return {
'a': a, 'b': 0, 'c': 0, 'd': d,
'chi2': 0.0, 'p_value': 1.0, 'significant': False,
'net_delta': 0.0, 'acc_m1': correct_1.float().mean().item(),
'acc_m2': correct_2.float().mean().item(),
'power_note': 'Models have identical predictions — no test possible.'
}
# McNemar statistic with Yates continuity correction
chi2 = (abs(b - c) - 1) ** 2 / (b + c)
p_value = float(1 - stats.chi2.cdf(chi2, df=1))
# Power note: b+c < 10 → very low power; b+c < 50 → moderate
if b + c < 10:
power_note = f"Very low power: only {b+c} discordant pairs. Consider larger test set."
elif b + c < 50:
power_note = f"Moderate power: {b+c} discordant pairs."
else:
power_note = f"Adequate power: {b+c} discordant pairs."
return {
'a': a,
'b': b,
'c': c,
'd': d,
'chi2': float(chi2),
'p_value': p_value,
'significant': p_value < alpha,
'net_delta': (c - b) / N,
'acc_m1': correct_1.float().mean().item(),
'acc_m2': correct_2.float().mean().item(),
'power_note': power_note,
}
# ── Usage example ────────────────────────────────────────────────────────────
torch.manual_seed(42)
N = 1000
y_true = torch.randint(0, 10, (N,))
y_pred_1 = (y_true + torch.randint(0, 3, (N,))) % 10 # ~70% accurate
y_pred_2 = (y_true + torch.randint(0, 4, (N,))) % 10 # ~65% accurate
result = mcnemar_test(y_true, y_pred_1, y_pred_2)
print(f"Contingency table:")
print(f" a (both correct): {result['a']}")
print(f" b (regressions): {result['b']}")
print(f" c (improvements): {result['c']}")
print(f" d (both wrong): {result['d']}")
print(f"Model 1 accuracy: {result['acc_m1']:.3f}")
print(f"Model 2 accuracy: {result['acc_m2']:.3f}")
print(f"χ² statistic: {result['chi2']:.3f}")
print(f"p-value: {result['p_value']:.4f}")
print(f"Significant (α=0.05): {result['significant']}")
print(f"Power note: {result['power_note']}")
3.13 Extension: Multi-Class and Regression Problems
Multi-class classification: McNemar's test extends directly. "Correct" means the predicted class equals the true class, regardless of how many classes exist. The test remains binary (correct/wrong per example):
# Works for any number of classes — "correct" is still binary
correct_1 = (y_pred_1 == y_true)
correct_2 = (y_pred_2 == y_true)
# Apply mcnemar_test() as above
Regression (continuous outputs): Binary correct/wrong doesn't apply. Use the Wilcoxon signed-rank test instead — a non-parametric paired test for continuous paired differences:
from scipy.stats import wilcoxon
# Error per example (lower is better)
errors_1 = (y_pred_float_1 - y_true_float).abs().numpy()
errors_2 = (y_pred_float_2 - y_true_float).abs().numpy()
# H0: both models have the same error distribution
statistic, p_value = wilcoxon(errors_1, errors_2, alternative='greater')
# alternative='greater' tests H1: errors_1 > errors_2 (model 2 regressed)
print(f"Wilcoxon p-value: {p_value:.4f}")
Wilcoxon vs paired t-test: The paired t-test assumes errors are normally distributed. Neural network errors on real data often aren't (long-tailed). The Wilcoxon test is distribution-free (non-parametric) and more appropriate.
Section 4: CI/CD Integration — Automated Regression Gates
4.1 What Is a Deployment Gate?
A deployment gate is an automated check that must pass before a new model version can be deployed to production. Without gates, regressions slip through whenever:
- Engineers are in a hurry
- The test set isn't run consistently
- Reviewers look at absolute accuracy but not statistical significance
A gate codifies the decision criteria in code, runs on every PR/model checkpoint, and blocks deployment automatically if criteria fail.
4.2 Multi-Criterion Gate Implementation
class RegressionGate:
"""
Multi-criterion deployment gate for model accuracy.
Policy — block deployment if ANY of these fail:
1. Absolute accuracy below floor (absolute safety)
2. Accuracy delta exceeds threshold (raw change check)
3. McNemar's test: significant regression in the statistical sense
All three criteria are necessary:
- Criterion 1 alone: misses gradual degradation over many releases
- Criterion 2 alone: blocks on noise (not significant changes)
- Criterion 3 alone: allows continuous slow drift if each step is insignificant
"""
def __init__(
self,
threshold_delta: float = -0.01, # max allowed accuracy drop (absolute)
min_accuracy: float = 0.80, # floor — never ship below this
alpha: float = 0.05, # significance level
):
self.threshold_delta = threshold_delta
self.min_accuracy = min_accuracy
self.alpha = alpha
def evaluate(
self,
y_true: torch.Tensor,
y_pred_baseline: torch.Tensor,
y_pred_candidate: torch.Tensor,
task_name: str = "",
) -> dict:
mc = mcnemar_test(y_true, y_pred_baseline, y_pred_candidate, self.alpha)
acc_base = mc['acc_m1']
acc_new = mc['acc_m2']
delta = acc_new - acc_base
checks = {
'floor': acc_new >= self.min_accuracy,
'accuracy_delta': delta >= self.threshold_delta,
'statistical': not (mc['significant'] and delta < 0),
# Only block on stats if the test confirms a real regression AND
# the direction is indeed a regression (not an improvement)
}
return {
'passed': all(checks.values()),
'checks': checks,
'delta': delta,
'acc_baseline': acc_base,
'acc_candidate': acc_new,
'mcnemar_p': mc['p_value'],
'regressions': mc['b'],
'improvements': mc['c'],
'power_note': mc['power_note'],
'task': task_name,
}
# ── Usage in CI pipeline ──────────────────────────────────────────────────
gate = RegressionGate(threshold_delta=-0.005, min_accuracy=0.85)
result = gate.evaluate(y_true, baseline_preds, candidate_preds, "imagenet_val")
if not result['passed']:
failed_checks = [k for k, v in result['checks'].items() if not v]
raise SystemExit(
f"Deployment blocked for task '{result['task']}'!\n"
f" Accuracy: {result['acc_baseline']:.4f} → {result['acc_candidate']:.4f}"
f" (Δ={result['delta']:+.4f})\n"
f" Regressions: {result['regressions']}, Improvements: {result['improvements']}\n"
f" McNemar p: {result['mcnemar_p']:.4f}\n"
f" Power: {result['power_note']}\n"
f" Failed: {failed_checks}"
)
4.3 Combining Pareto Analysis with Statistical Testing
Complete end-to-end evaluation pipeline:
def full_evaluation_pipeline(
models: list,
eval_data,
metrics_fn, # returns [accuracy, latency_ms, memory_mb, ...]
) -> list[dict]:
"""
1. Evaluate all candidate models on all metrics
2. Compute Pareto front (multi-objective, non-dominated set)
3. For each Pareto-optimal model, run McNemar vs baseline (model 0)
4. Identify the knee point among Pareto-optimal models
5. Return ranked recommendations
Returns list of recommendation dicts, sorted by knee-point distance.
"""
N = len(models)
all_preds = {}
all_scores = {} # [accuracy_norm, 1/latency, 1/memory] — all ↑
for i, model in enumerate(models):
preds, latency_ms, memory_mb = run_inference(model, eval_data)
acc = (preds == eval_data.labels).float().mean().item()
all_preds[i] = preds
all_scores[i] = torch.tensor([acc, 1.0/latency_ms, 1.0/memory_mb])
scores_tensor = torch.stack([all_scores[i] for i in range(N)]) # [N, 3]
# Step 2: Pareto front
pareto_mask = pareto_front_vectorized(scores_tensor)
pareto_ids = pareto_mask.nonzero().squeeze(1).tolist()
# Step 3: Knee point within Pareto front
pareto_scores = scores_tensor[pareto_mask] # [K, 3]
knee_local = find_knee_point(pareto_scores) # index in pareto_scores
knee_global = pareto_ids[knee_local]
# Step 4: McNemar vs baseline for each Pareto-optimal model
baseline_preds = all_preds[0]
recommendations = []
for model_id in pareto_ids:
if model_id == 0:
mc = {'b': 0, 'c': 0, 'p_value': None, 'significant': False,
'acc_m1': all_scores[0][0].item(), 'acc_m2': all_scores[0][0].item()}
else:
mc = mcnemar_test(eval_data.labels, baseline_preds, all_preds[model_id])
recommendations.append({
'model_id': model_id,
'accuracy': all_scores[model_id][0].item(),
'latency_proxy': all_scores[model_id][1].item(),
'memory_proxy': all_scores[model_id][2].item(),
'mcnemar': mc,
'is_knee_point': (model_id == knee_global),
'is_baseline': (model_id == 0),
})
return sorted(recommendations, key=lambda x: not x['is_knee_point'])
Section 5: Sample Size Planning
5.1 Why Sample Size Matters Before You Collect Data
The worst time to discover your test set is too small is after running the evaluation. If you have 200 test examples and your models agree on 95% of them, you have only ~10 discordant pairs — far too few for a reliable McNemar's test.
Sample size planning is done before data collection:
- Decide the minimum accuracy delta worth detecting (e.g., 1%)
- Estimate the expected disagreement rate between models (e.g., 10%)
- Choose desired power (80% is standard)
- Solve for the required N
5.2 Deriving the Formula
Under H₁ (true accuracy delta = δ):
- Expected discordant pairs: $(b + c) \approx N \times r$ where $r$ is the disagreement rate
- Expected net imbalance: $(b - c) \approx N \times \delta$ (because δ extra examples are regressions)
For the McNemar statistic to exceed the critical value $z_{\alpha/2}$ with probability $1-\beta$:
$$\frac{|b-c|}{\sqrt{b+c}} \approx z_{\alpha/2} + z_\beta$$
Substituting: $$\frac{N\delta}{\sqrt{Nr}} \approx z_{\alpha/2} + z_\beta$$ $$\frac{\sqrt{N} \cdot \delta}{\sqrt{r}} \approx z_{\alpha/2} + z_\beta$$ $$\sqrt{N} \approx \frac{(z_{\alpha/2} + z_\beta) \sqrt{r}}{\delta}$$ $$N \approx \left(\frac{z_{\alpha/2} + z_\beta}{\delta}\right)^2 \cdot r$$
For $\alpha = 0.05$ (two-sided), $z_{\alpha/2} = 1.96$. For 80% power, $z_\beta = 0.84$. So $z_{\alpha/2} + z_\beta = 2.80$.
5.3 Implementation
from scipy.stats import norm
import numpy as np
def sample_size_for_mcnemar(
delta_acc: float, # minimum delta to detect (e.g., 0.01 = 1%)
expected_disagreement_rate: float = 0.10, # expected b+c as fraction of N
power: float = 0.80, # 0.80 = 80% power
alpha: float = 0.05, # significance level
) -> int:
"""
Compute minimum N for McNemar's test.
Example interpretation:
delta_acc=0.02, expected_disagreement_rate=0.15, power=0.80:
→ Need N examples such that with 80% probability, we detect a 2% regression
when ~15% of examples differ between models.
Typical values:
- Similar models (int8 PTQ of fp16): disagreement rate ~5-15%
- Different architectures: disagreement rate ~20-40%
"""
z_alpha = norm.ppf(1 - alpha / 2) # 1.96 for α=0.05
z_power = norm.ppf(power) # 0.84 for 80% power
N = ((z_alpha + z_power) / delta_acc) ** 2 * expected_disagreement_rate
return int(np.ceil(N))
# ── Example calls ─────────────────────────────────────────────────────────
print("Sample sizes needed:")
for delta in [0.005, 0.01, 0.02, 0.05]:
for disagree_rate in [0.05, 0.10, 0.20]:
N = sample_size_for_mcnemar(delta, disagree_rate)
print(f" δ={delta:.1%}, disagreement={disagree_rate:.0%} → N={N:,}")
# Typical output:
# δ=0.5%, disagreement=5% → N=15,680
# δ=0.5%, disagreement=10% → N=31,360
# δ=1.0%, disagreement=5% → N=3,920
# δ=1.0%, disagreement=10% → N=7,840
# δ=2.0%, disagreement=5% → N=980
# δ=2.0%, disagreement=10% → N=1,960
# δ=5.0%, disagreement=5% → N=157
# δ=5.0%, disagreement=10% → N=314
Rule of thumb for production:
- Aim for ≥ 1000 test examples (detects ~2% delta at 10% disagreement with 80% power)
- For safety-critical models: 5000+ examples
- For detecting sub-1% regressions: 10,000+ examples
Section 6: End-to-End: Evaluating Quantized Model Variants
A common scenario: compare FP16 baseline against INT8-PTQ, INT4-GPTQ, and INT4-QAT variants.
def evaluate_quantization_tradeoffs(models_dict: dict, test_data) -> None:
"""
models_dict: {'fp16': model_fp16, 'int8_ptq': ..., 'int4_gptq': ..., 'int4_qat': ...}
Produces:
- Full Pareto analysis (accuracy vs latency vs memory)
- McNemar test for each variant vs FP16 baseline
- Knee point recommendation
"""
results = {}
for name, model in models_dict.items():
preds, latency_ms, memory_mb = benchmark_model(model, test_data)
acc = (preds == test_data.labels).float().mean().item()
results[name] = {
'preds': preds,
'accuracy': acc,
'latency_ms': latency_ms,
'memory_mb': memory_mb,
}
# Score matrix — all higher = better
names = list(results.keys())
scores = torch.tensor([
[results[n]['accuracy'],
1.0 / results[n]['latency_ms'],
1.0 / results[n]['memory_mb']]
for n in names
])
pareto_mask = pareto_front_vectorized(scores)
knee_idx = find_knee_point(scores[pareto_mask])
fp16_preds = results['fp16']['preds']
print(f"\n{'Model':<16} {'Acc':>8} {'Lat(ms)':>10} {'Mem(MB)':>10} "
f"{'Pareto':>8} {'Knee':>6} {'p-val':>8}")
print('─' * 72)
pareto_ctr = 0
for i, name in enumerate(names):
is_pareto = pareto_mask[i].item()
is_knee = is_pareto and (pareto_ctr == knee_idx)
if is_pareto:
pareto_ctr += 1
if name != 'fp16':
mc = mcnemar_test(test_data.labels, fp16_preds, results[name]['preds'])
p_str = f"{mc['p_value']:.3f}{'*' if mc['significant'] else ' '}"
else:
p_str = "baseline"
print(f"{name:<16} {results[name]['accuracy']:>8.4f} "
f"{results[name]['latency_ms']:>10.1f} "
f"{results[name]['memory_mb']:>10.0f} "
f"{'✓' if is_pareto else '✗':>8} "
f"{'◉' if is_knee else ' ':>6} "
f"{p_str:>8}")
# Typical output:
#
# Model Acc Lat(ms) Mem(MB) Pareto Knee p-val
# ────────────────────────────────────────────────────────────────────────
# fp16 0.9234 124.0 6300 ✓ baseline
# int8_ptq 0.9218 62.5 1580 ✓ 0.612
# int4_gptq 0.9147 35.2 810 ✓ 0.031*
# int4_qat 0.9201 35.2 810 ✓ ◉ 0.274
#
# Interpretation:
# - int8_ptq: no significant regression (p=0.612), 2× faster → good candidate
# - int4_gptq: significant regression (p=0.031*, p<0.05) → risky
# - int4_qat: no significant regression (p=0.274), same speed as gptq → knee point
Section 7: Interview Questions — Principal-Level Answers
Q: "Why McNemar's and not a paired t-test for accuracy comparison?"
McNemar's is designed for paired binary outcomes — right/wrong per example. A paired t-test assumes your measured values are approximately normally distributed continuous quantities. Accuracy per example is 0 or 1, not normally distributed. More importantly, McNemar's uses only the discordant pairs (examples where models disagree) as the effective sample. When comparing two classifiers on the same test set, the outcomes are correlated — both models tend to get easy examples right and hard examples wrong. This correlation means that if both models get 990 out of 1000 examples right in the same way, the effective evidence for comparison comes from only those 10 disagreements. McNemar's uses 10 as the denominator (not 1000), giving the correct answer. A t-test would use 1000 and underestimate uncertainty.
Q: "Model A has 85% accuracy, model B has 83% accuracy, model B is 3× faster. Which do you ship?"
Neither yet — there are two separate questions to answer. First: is the 2% delta statistically real? Run McNemar's test. If p > 0.05, the delta is noise and model B is strictly better (faster, same real accuracy). If p < 0.05, the delta is real — then it becomes a business decision. Is 83% above the product floor? Is the 3× speedup worth the 2% accuracy cost? If the product requires ≥ 85%, model B is eliminated. If 83% meets the floor, model B is Pareto-optimal (dominates everything between 83-85% accuracy at 3× latency). The decision cannot be made unilaterally by the ML engineer — it requires explicit sign-off from the product owner on the accuracy vs. speed tradeoff.
Q: "What's the minimum test set size for a reliable evaluation?"
It depends on what you want to detect. Use the formula: $N \approx (z_{\alpha/2} + z_\beta)^2 \times r / \delta^2$ where $r$ is the expected disagreement rate and $\delta$ is the smallest regression worth catching. For 80% power, α=0.05, 10% disagreement rate: detecting 2% delta needs ~2000 examples; detecting 1% delta needs ~8000. The key insight is that statistical power scales with the number of discordant pairs, not total test set size. Two models that are very similar (agree on 99% of examples) need a huge test set to get enough disagreements for a reliable test.
Q: "Explain Pareto dominance without using the word 'dominate'."
Model A makes model B irrelevant if A is at least as good as B on every single metric, and strictly better on at least one. The Pareto front is the set of models where none of them makes any other one irrelevant — every model on the front offers a distinct, genuine tradeoff. If you want maximum accuracy and don't care about speed, you pick from one end. If you need maximum speed and can accept lower accuracy, you pick from the other end. The knee point is the model that seems to offer the best deal when you weigh all metrics equally — geometrically, it's the model closest to the impossible ideal of being best at everything simultaneously.
Q: "Your team just increased test set size from 500 to 5000 examples and suddenly 3 previously non-significant regressions became significant. Did your model suddenly get worse?"
No — the model didn't change. What changed is your ability to detect real effects. With 500 examples, you had low statistical power — real regressions of 1-2% were invisible in the noise. With 5000 examples, you now have enough statistical power to reliably detect those same real differences that always existed. This is the correct outcome. The 3 regressions were always there; you just couldn't see them. This illustrates why sample size planning before evaluation is critical — if you had run with 5000 examples from the start, you would have made better shipping decisions all along.
Q: "How do you handle evaluation when you have 50+ candidate models from a hyperparameter sweep?"
Two-step process. First, Pareto analysis: compute the Pareto front across all 50+ models on all metrics. Typically only 3-10 models will be Pareto-optimal — the rest are dominated and can be eliminated immediately without any statistical testing. Second, for the Pareto-optimal models, run McNemar's test against the current production baseline. This controls Type I error: if you ran 50 McNemar tests without correction, you'd expect 2-3 false positives at α=0.05. But since Pareto filtering reduces the candidate set to ~5-10 models, the multiple testing problem is manageable. If you need strict control, apply Bonferroni correction: use α' = 0.05 / (number of Pareto-optimal models tested).
Section 8: Quick Setup and Tests
pip install torch>=2.1.0 scipy numpy
Test: Pareto analysis
import torch
# Paste the pareto_front_vectorized and find_knee_point functions above
scores = torch.tensor([
[0.92, 0.33, 0.80], # M1
[0.88, 0.83, 0.70], # M2
[0.85, 1.00, 0.90], # M3
[0.88, 0.67, 0.70], # M4 — dominated by M2
[0.80, 0.50, 0.60], # M5 — dominated by M3
])
pareto = pareto_front_vectorized(scores)
assert pareto.tolist() == [True, True, True, False, False], f"Got: {pareto.tolist()}"
print("Pareto-optimal models:", pareto.nonzero().flatten().tolist()) # [0, 1, 2]
knee = find_knee_point(scores[pareto])
print(f"Knee point: model index {pareto.nonzero().flatten()[knee].item()}") # 2 (M3)
Test: McNemar's test
import torch
# Paste the mcnemar_test function above
torch.manual_seed(0)
N = 1000
y_true = torch.randint(0, 5, (N,))
y_old = (y_true + torch.randint(0, 3, (N,))) % 5 # ~70% accurate
y_new = (y_true + torch.randint(0, 3, (N,))) % 5 # similar accuracy
result = mcnemar_test(y_true, y_old, y_new)
print(f"Contingency: a={result['a']}, b={result['b']}, c={result['c']}, d={result['d']}")
print(f"χ² = {result['chi2']:.3f}")
print(f"p = {result['p_value']:.4f}")
print(f"Significant: {result['significant']}")
print(result['power_note'])
Test: Sample size planning
# Paste the sample_size_for_mcnemar function above
# Verify: to detect a 2% delta with 10% disagreement rate and 80% power
N = sample_size_for_mcnemar(delta_acc=0.02, expected_disagreement_rate=0.10)
print(f"Need {N} examples to detect 2% delta (10% disagreement rate, 80% power)")
# Expected: ~1960 examples
References
Foundational Papers
-
McNemar, Q. (1947). "Note on the sampling error of the difference between correlated proportions or percentages." Psychometrika, 12(2), 153–157. — The original paper introducing the test.
-
Dietterich, T. G. (1998). "Approximate Statistical Tests for Comparing Supervised Classification Learning Algorithms." Neural Computation, 10(7), 1895–1923. — Essential comparison of McNemar's vs other tests for ML classifiers; shows McNemar's is best for paired evaluation.
-
Demšar, J. (2006). "Statistical Comparisons of Classifiers over Multiple Data Sets." Journal of Machine Learning Research, 7, 1–30. — Definitive guide to comparing classifiers; covers Wilcoxon, Bonferroni corrections, multi-dataset comparison.
Multi-Objective Optimization
-
Pareto, V. (1896). Cours d'économie politique. — Original formulation of Pareto efficiency.
-
Deb, K., Pratap, A., Agarwal, S., & Meyarivan, T. (2002). "A fast and elitist multiobjective genetic algorithm: NSGA-II." IEEE Transactions on Evolutionary Computation, 6(2), 182–197. — NSGA-II: the standard Pareto-front algorithm in evolutionary computation; basis for many ML hyperparameter search frameworks.
-
Loshchilov, I., & Hutter, F. (2016). "CMA-ES for Hyperparameter Optimization of Deep Neural Networks." ICLR Workshop. — Pareto analysis applied to DNN hyperparameter search.
Statistical Testing for ML
-
Efron, B., & Hastie, T. (2016). Computer Age Statistical Inference. Cambridge University Press. — Chapter 3 covers hypothesis testing; Chapter 15 covers bootstrap methods for ML evaluation.
-
Kohavi, R., & Provost, F. (1998). "Glossary of Terms." Machine Learning, 30, 271–274. — Definitive ML terminology for evaluation metrics.
-
Yates, F. (1934). "Contingency tables involving small numbers and the χ² test." Journal of the Royal Statistical Society, 1(Suppl.), 217–235. — Original continuity correction paper.
Practical ML Evaluation
-
Ribeiro, M. T., Wu, T., Guestrin, C., & Singh, S. (2020). "Beyond Accuracy: Behavioral Testing of NLP Models with CheckList." ACL 2020. — How to build rigorous eval beyond single-number accuracy.
-
Liang, P., et al. (2022). "Holistic Evaluation of Language Models (HELM)." arXiv:2211.09110. — Canonical multi-metric, multi-objective evaluation framework for LLMs.
-
Hooker, S., et al. (2019). "A Benchmark for Interpretability Methods in Deep Neural Networks." NeurIPS 2019. — Statistical rigor in model comparison for interpretability research.
On-Device / Production Evaluation
-
Qualcomm AI Hub Documentation —
aihub.qualcomm.com— Model profiling, on-device benchmarking, latency/memory/power metrics. The practical reference for Qualcomm NPU evaluation. -
Reddi, V. J., et al. (2020). "MLPerf Inference Benchmark." arXiv:1911.02549. — Industry standard for multi-metric, hardware-aware model evaluation.
Python / PyTorch References
-
SciPy documentation —
scipy.stats.chi2— CDF, PPF, and PDF for chi-squared distribution used in McNemar's p-value computation. -
PyTorch documentation — Broadcasting semantics —
pytorch.org/docs/stable/notes/broadcasting— Explains theunsqueeze+ broadcast trick used in the vectorized Pareto front.
Lab 01 — Evaluation Harness
Phase: 09 — Model Accuracy Evaluation | Difficulty: ⭐⭐⭐⭐☆ | Time: 3–4 hours
A production eval harness runs benchmarks, collects metrics, detects regressions, and generates reports suitable for CI/CD pipelines.
What you build
BenchmarkTask— wraps a dataset + metric functionEvalHarness— runs multiple tasks, aggregates per-task and overall scores- Integration with
lm_evalformat (few-shot benchmarks: MMLU, HellaSwag, ARC) - Regression detection: compare against baseline with configurable delta threshold
- JSON report generation for CI artifacts
Key concepts
| Concept | What to understand |
|---|---|
| lm-evaluation-harness | Open-source framework for standardized LLM benchmarks |
| Few-shot prompting | Evaluate with N in-context examples — tests generalization |
| Accuracy vs F1 | MMLU uses accuracy; NER/QA use F1; generation uses ROUGE/BLEU |
| CI integration | Eval must be reproducible and deterministic for regression detection |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Lab 02 — Pareto Analyzer
Phase: 09 — Model Accuracy Evaluation | Difficulty: ⭐⭐⭐⭐⭐ | Time: 3–4 hours
When optimizing for both accuracy AND latency, no single "best" model exists — you need Pareto analysis.
What you build
ModelPoint— dataclass withaccuracy,latency_ms,model_namedominates(a, b)— Pareto dominance: a dominates b iff both ≥ and at least one strictcompute_pareto_front— filter out all dominated modelsfind_knee_point— maximize distance from utopian point (1,1) in normalized spaceweighted_score— combine metrics with configurable weightsrank_by_weighted_score— sorted ranking for decision-making
Key concepts
| Concept | What to understand |
|---|---|
| Pareto dominance | a dominates b: a is no worse in all objectives and better in ≥1 |
| Pareto front | Set of non-dominated models — the "best tradeoffs" |
| Knee point | Model closest to utopian (max accuracy, min latency) — practical default |
| Utopian point | (best_accuracy, best_latency) — unreachable but guides selection |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Lab 03 — Regression Detection with McNemar's Test
Phase: 09 — Model Accuracy Evaluation | Difficulty: ⭐⭐⭐⭐⭐ | Time: 3–4 hours
McNemar's test is the correct statistical test for detecting accuracy regressions between two models on the same dataset.
What you build
mcnemar_test— χ² statistic and p-value for paired categorical comparisonsRegressionDetector— stores baseline, compares new model per-taskRegressionPolicy— per-task delta gate + average delta gate for CI pass/failanalyze_slices— break down accuracy by data slice (domain, length, etc.)RegressionResult— full report with pass/fail status
Key concepts
| Concept | What to understand |
|---|---|
| McNemar's test | For paired binary outcomes: χ²=( |
| b, c cells | b = old correct, new wrong; c = old wrong, new correct |
| Why not t-test? | McNemar handles correlated binary pairs — t-test assumes independence |
| CI regression gate | p < 0.05 AND delta > threshold → block deployment |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Phase 10 — GenAI on Edge & Embedded Systems
Difficulty: ⭐⭐⭐⭐⭐
Estimated Time: 3 weeks (70–90 hours)
Roles Supported: Core for Qualcomm — all Snapdragon-based AI is on-device; this phase covers the unique constraints and solutions for edge deployment
Why This Phase Exists
The "edge" changes everything. A 7B LLM needs 14 GB in FP16 — more than the DRAM on any current smartphone. The Snapdragon 8 Gen 3 has 12 GB shared DRAM for the entire phone OS. This is not a software problem you optimize around with better code; it requires fundamentally different algorithms.
This phase teaches the engineering decisions that make LLMs possible on edge devices: aggressive quantization (INT4 → 3.5B bytes for 7B parameters), speculative decoding, memory splitting across chip and DRAM, on-chip VTCM tiling, and LoRA-based accuracy recovery when aggressive quantization degrades quality below acceptable thresholds.
The GenAI on edge space is Qualcomm's core business differentiation. Every major phone OEM (Samsung, Xiaomi, OnePlus, OPPO) uses Snapdragon for their on-device AI features. A Senior Staff engineer in this space must understand the full stack from silicon capabilities to end-user accuracy thresholds.
Concepts
- Memory budget for edge: smartphone DRAM = 8–16 GB shared with OS + all apps; effective model DRAM budget = 2–4 GB; requires INT4 or INT2 quantization for 7B+ models
- VTCM (Vector Tightly Coupled Memory): 8 MB on-chip SRAM on HTP; much faster than DRAM (>4× bandwidth); weights that fit in VTCM during a layer's computation avoid DRAM; tiling strategy critical
- Prefill chunking: process long prompts in chunks to limit peak memory; each chunk = one forward pass; KV cache fills incrementally
- Layer-by-layer offload: keep only the current layer's weights in DRAM; offload unused layers to flash/eMMC storage; latency ~10× worse but enables 70B models on 4 GB DRAM
- Quantization formats for edge: GGUF (llama.cpp), GPTQ, AWQ, QNN DLC — each has different trade-offs for edge hardware; GGUF is most portable, DLC is optimal for Snapdragon
- LoRA (Low-Rank Adaptation): train low-rank adapter matrices $A \in \mathbb{R}^{d \times r}$, $B \in \mathbb{R}^{r \times k}$ for each weight; $W' = W + BA$; only $r \ll \min(d,k)$ new parameters; enables domain-specific fine-tuning with <1% of full parameters
- QLoRA: quantize the base model to INT4 (NF4 format) and fine-tune LoRA adapters in BF16; significantly reduces GPU memory for fine-tuning; PEFT library
- NF4 (Normal Float 4-bit): information-theoretically optimal quantization for normally distributed weights; ~0.5 PPL better than INT4 for same bit-budget
- Accuracy recovery strategies: LoRA fine-tuning on domain data, knowledge distillation from FP16 teacher, QAT with domain data, GPTQ per-layer accuracy recovery
- On-device learning: fine-tune LoRA on the device using only local data; privacy-preserving; federated learning; requires INT8 gradient computation
- Memory-constrained attention: ring attention (distribute sequence across devices), sliding window (limit effective context), streaming LLM (fixed-size attention window with sink tokens)
- Prefill/decode asymmetry on NPU: prefill is compute-bound (many tokens at once → large matmuls); decode is memory-bound (one token at a time → small matmuls); requires different optimization strategies
Labs
Lab 01 — On-Device LLM: INT4 Quantize, Benchmark, Accuracy Delta
| Field | Value |
|---|---|
| Goal | Quantize LLaMA-3.2-1B to multiple INT4 formats (GPTQ, AWQ, NF4/QLoRA), benchmark inference on CPU and AI Hub NPU simulator, and comprehensively measure accuracy degradation vs FP16 baseline |
| Concepts | GPTQ vs AWQ vs NF4 at INT4, memory footprint calculation, tokens/sec on NPU, accuracy delta (PPL, MMLU, WinoGrande), size/accuracy/latency trade-off table |
| Steps | 1. Compute FP16 baseline metrics (PPL, MMLU 5-shot, WinoGrande); 2. Apply GPTQ INT4 (g=128) using auto-gptq; 3. Apply AWQ INT4 (g=128) using autoawq; 4. Apply NF4 quantization via bitsandbytes; 5. For each format: (a) measure model file size, (b) measure DRAM usage during inference, (c) measure tokens/sec on CPU, (d) submit to AI Hub for HTP timing, (e) measure accuracy metrics; 6. Produce comprehensive comparison table |
| Stack | auto-gptq, autoawq, bitsandbytes, qai_hub, transformers |
| Datasets | WikiText-2, MMLU (200 questions for speed), WinoGrande val |
| Output | Comparison table: format × (size, DRAM, latency_cpu, latency_npu, PPL, MMLU, WinoGrande); recommended configuration for 3 deployment scenarios (battery-constrained, quality-constrained, latency-constrained) |
| How to Test | pytest test_lab.py — checks: quantized models produce valid text, accuracy metrics are within expected ranges (PPL < 8 for INT4), DRAM usage is less than FP16, AI Hub job completes |
| Talking Points | "Which INT4 format would you recommend for Snapdragon deployment?" — explain: NF4 for accuracy, AWQ for speed, GPTQ for best accuracy if you can spend the quantization time; DLC format for actual deployment |
| Resume Bullet | Benchmarked 3 INT4 quantization formats for LLaMA-3.2-1B on Snapdragon 8 Gen 3; GPTQ achieved best quality (PPL 6.4 vs 5.9 FP16 baseline) at 1.8 GB DRAM and 14 tok/s on HTP |
| Extensions | Add INT2 (AQLM) for extreme compression; test LLaMA-3.2-3B with layer-by-layer offload; measure per-query energy consumption |
Lab 02 — LoRA Fine-Tuning for Accuracy Recovery
| Field | Value |
|---|---|
| Goal | After aggressive INT4 quantization (which degraded domain accuracy), use QLoRA to fine-tune the quantized model on domain data, recovering accuracy while maintaining the INT4 memory footprint |
| Concepts | LoRA rank and alpha hyperparameters, PEFT library, QLoRA (fine-tuning INT4 base + BF16 adapters), gradient checkpointing, domain dataset preparation, merge vs separate LoRA weights for deployment |
| Steps | 1. Take the INT4 GPTQ model from Lab 01 that showed the most accuracy degradation; 2. Prepare domain fine-tuning dataset (use medical QA or legal text from HuggingFace, ~10K examples); 3. Configure QLoRA: rank=64, alpha=128, target_modules=["q_proj","v_proj","k_proj","o_proj"]; 4. Fine-tune for 2 epochs with AdamW + cosine LR; 5. Evaluate domain accuracy before/after LoRA; 6. Try ranks r=[8, 16, 32, 64, 128] and plot accuracy vs trainable params; 7. Merge LoRA weights into base and re-quantize for deployment |
| Stack | peft, bitsandbytes, transformers, trl |
| Datasets | MedQA (medical questions) or CourtListener (legal), WikiText-2 |
| Output | LoRA-recovered model; accuracy vs rank plot; domain accuracy before/after recovery; training curves; merged deployable model |
| How to Test | pytest test_lab.py — checks: LoRA adapter shapes are correct, merged model weights differ from base, domain accuracy improves by >1% vs unrecovered model, trainable parameter count matches expected for given rank |
| Talking Points | "When would you use LoRA rank=64 vs rank=8?" — larger rank = more capacity but more memory and slower inference; "How do you choose target modules for LoRA?" — typically attention projections; "Can LoRA recovery work at INT2?" — usually not — minimum INT4 for gradient flow |
| Resume Bullet | Applied QLoRA to recover domain accuracy in INT4-quantized LLaMA model; achieved 89% of FP16 accuracy on medical QA using only 1.2% additional trainable parameters (rank=64 LoRA) |
| Extensions | Add LoRA rank adaptation during training; implement DoRA (Weight-Decomposed Low-Rank Adaptation); merge and re-quantize with quantization-aware LoRA merge |
Lab 03 — Memory-Constrained Serving: Layer-by-Layer Offload
| Field | Value |
|---|---|
| Goal | Implement a memory-constrained inference engine for a model that doesn't fit in DRAM: stream layers from disk (simulated NVMe/eMMC), run inference one layer at a time, and measure the latency overhead vs fully-loaded inference |
| Concepts | Layer streaming, prefetching (overlap compute and load), CPU offload, mmap for zero-copy loading, sliding-window context (for infinite context with bounded memory), activation checkpointing at inference |
| Steps | 1. Save each transformer layer's weights as a separate .pt file (simulates NVMe storage); 2. Implement StreamingTransformer that loads layer $i$ → runs it → deletes activations → loads layer $i+1$; 3. Add async prefetching: start loading layer $i+1$ while layer $i$ is computing; 4. Implement sliding window for long contexts (keep only last W tokens in KV cache); 5. Benchmark: fully-loaded vs layer-streaming (no prefetch) vs layer-streaming (with prefetch); 6. Find minimum DRAM budget that still achieves acceptable latency |
| Stack | PyTorch 2.3+, asyncio, mmap, transformers |
| Datasets | WikiText-2 long documents (1000+ tokens) |
| Output | StreamingTransformer class; latency vs DRAM budget trade-off curve; prefetch effectiveness analysis; minimum viable DRAM configuration |
| How to Test | pytest test_lab.py — checks: streaming output matches full-load output, memory peak is bounded by streaming config, prefetch reduces latency vs sequential load, sliding window handles sequences longer than cache |
| Talking Points | "How would you deploy a 70B LLM on a 4 GB DRAM device?" — layer streaming + INT4 + sliding window + prefetch; "What determines the optimal prefetch depth?" — ratio of compute time to memory transfer time |
| Resume Bullet | Implemented layer-streaming inference engine enabling 7B LLM inference at 2 GB DRAM; async prefetching recovered 71% of the streaming latency overhead vs fully-loaded inference |
| Extensions | Add CPU↔GPU offload (for GPU with limited VRAM); implement KV cache compression (group quantize to INT4); add speculative prefetching based on predicted layer access pattern |
Deliverables Checklist
- INT4 quantization comparison across 3 formats with AI Hub benchmarks
- QLoRA recovery workflow with rank sensitivity analysis
- Layer-streaming inference engine with prefetch optimization
-
All
test_lab.pysuites pass
Interview Relevance
- "How would you run LLaMA-7B on a device with 4 GB DRAM?" — you describe: INT4 quantization (1.75 GB) + layer streaming + prefetch + sliding window context
- "After INT4 quantization, our chatbot accuracy on medical queries dropped 8%. What do you do?" — QLoRA fine-tuning on medical domain data, targeting attention projections, rank=64
- "What is the difference between LoRA rank and the number of fine-tuned parameters?" — trainable params = 2 × r × (sum of target module dimensions); explain the rank-capacity trade-off
Warmup Guide — GenAI on Edge
Zero-to-expert primer for Phase 10. Builds edge LLM deployment from the memory budget up: AWQ quantization, QLoRA/NF4 adapters for accuracy recovery, and layer streaming for models that don't fit.
Table of Contents
- Chapter 1: The Edge Budget — Doing the Arithmetic First
- Chapter 2: AWQ — Protecting the Weights That Matter
- Chapter 3: LoRA — Low-Rank Adaptation from Zero
- Chapter 4: QLoRA — NF4, Double Quantization, and Recovery Training
- Chapter 5: Adapters as Accuracy-Recovery for Quantization
- Chapter 6: Layer Streaming — Running Models Bigger Than Memory
- Chapter 7: The Edge Serving Stack
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: The Edge Budget — Doing the Arithmetic First
Every edge deployment starts with three numbers. For a phone: ~6–8 GB total RAM (of which an app may use 2–4), ~50–100 GB/s memory bandwidth, ~3–5 W sustained thermal budget. Now budget a LLaMA-3.2-1B chat assistant:
| Item | FP16 | INT4 (g=128) |
|---|---|---|
| Weights | 2.5 GB | ~0.7 GB |
| KV cache @ 4K ctx (GQA, 8 KV heads, 16 layers) | ~0.5 GB | ~0.25 GB (INT8 KV) |
| Activations + runtime | ~0.3 GB | ~0.3 GB |
| Total | 3.3 GB — marginal | ~1.25 GB — comfortable |
And decode speed (Phase 07's bandwidth formula): reading 0.7 GB per token at an achieved 30 GB/s → ~40 tok/s ceiling; FP16 would cap at ~12. Quantization is the difference between shippable and not — and that's a 1B model. For 7B-class on device, INT4 is mandatory and Chapter 6's streaming may be too.
The phase's three labs are the three levers: make INT4 accurate (AWQ), recover what INT4 lost (QLoRA adapters), and escape the memory wall entirely (streaming).
Chapter 2: AWQ — Protecting the Weights That Matter
The observation (Lin et al.): weight importance is wildly non-uniform, and the important ~1% of weight channels are identifiable from activations — channels that multiply large activation magnitudes contribute most to outputs. Keeping just those channels in FP16 nearly recovers FP16 perplexity — but mixed-precision storage is hardware-hostile.
The trick — scale instead of split: for a per-(input-)channel scale $s > 1$,
$$y = \sum_j w_j x_j = \sum_j (w_j s_j) \cdot (x_j / s_j)$$
Scale the salient weight channels up by $s$ before quantization (their relative rounding error shrinks — the quantization grid covers them with more steps), and fold $1/s$ into the preceding op (the activation side), exactly like SmoothQuant in reverse direction. Choose $s$ per channel from activation magnitude statistics: $s_j = (\bar{|x_j|})^{\alpha}$, with $\alpha \in [0,1]$ grid-searched on a small calibration set against layer reconstruction error.
Why AWQ over GPTQ on edge: no Hessian, no Cholesky — calibration is a few hundred forward passes and a 1-D grid search per layer (minutes, not hours); it composes cleanly with per-group INT4 layouts that NPU kernels want; and it generalizes well from small calibration sets (less overfitting risk than GPTQ's error compensation). GPTQ still wins some perplexity benchmarks; AWQ wins deployment ergonomics — know both (Phase 03 Lab 03 built GPTQ; Lab 01 here builds AWQ).
Chapter 3: LoRA — Low-Rank Adaptation from Zero
The fine-tuning problem: full fine-tuning of even a 1B model needs gradients + optimizer states ≈ 4× weight memory in mixed precision — far beyond edge, and produces a full model copy per task.
The hypothesis: the change a fine-tune makes to a weight matrix has low intrinsic rank. So freeze $W \in \mathbb{R}^{d \times k}$ and learn only a low-rank correction:
$$W' = W + \Delta W = W + \frac{\alpha}{r} B A, \qquad B \in \mathbb{R}^{d \times r},; A \in \mathbb{R}^{r \times k},; r \ll \min(d,k)$$
- Parameters: $r(d + k)$ vs $dk$ — for $d{=}k{=}4096, r{=}16$: 0.8% of the matrix.
- $A$ initialized Gaussian, $B$ initialized zero → $\Delta W = 0$ at start: training begins exactly at the pretrained model (no cold-start damage).
- $\alpha/r$ decouples the learning-rate-like magnitude from the rank choice.
- At inference: either merge ($W' = W + \frac{\alpha}{r}BA$, zero overhead, loses swappability) or keep separate (tiny extra matmul, adapters hot-swappable per task — the edge-relevant mode: one base model, many ~10 MB task adapters).
Chapter 4: QLoRA — NF4, Double Quantization, and Recovery Training
QLoRA's contribution: train LoRA adapters on top of a quantized base model — backprop through frozen 4-bit weights into FP16 adapters. Three pieces:
- NF4 (NormalFloat-4): weights are approximately Gaussian; a uniform INT4 grid wastes levels on the tails. NF4's 16 levels are the quantiles of a standard normal — information-theoretically optimal for normal data, equal probability mass per bin rather than equal width. Implemented as a 16-entry lookup table per dequantize; absmax-scaled per block of 64.
- Double quantization: per-block FP32 absmax constants are themselves quantized (FP8 with a second-level constant) — saves ~0.4 bits/parameter, which at 7B scale is ~370 MB. Edge cares.
- The gradient path: forward dequantizes $W_{nf4}$ block-by-block to BF16 and adds $\frac{\alpha}{r}BA$; backward flows through the dequantize (it's just a scale — differentiable) into $A, B$ only. The base stays frozen and 4-bit; optimizer states exist only for the adapters. Memory: 7B fine-tune fits in a single 24 GB GPU — or a high-end devkit.
Chapter 5: Adapters as Accuracy-Recovery for Quantization
The Phase-10 synthesis (and Lab 02's actual shape): use the adapter to repair quantization damage, not to learn a new task.
- Quantize the base model (AWQ INT4, Lab 01). Measure the accuracy gap vs FP16.
- Add LoRA adapters; train them on general data (or distill: minimize KL between the quantized+adapter model's logits and the FP16 model's logits) — the adapter learns a low-rank approximation of the quantization error's functional effect.
- Ship: INT4 weights + ~1% FP16 adapter ≈ FP16 accuracy at INT4 memory/bandwidth.
This works because quantization error is structured, not random — dominated by the salient channels AWQ couldn't fully protect — and structured error is exactly what a low-rank corrector can absorb. It is the practical answer to "we lost 2 points at INT4 and QAT is too expensive": adapter recovery costs hours, not days, and never touches the base weights (one artifact serves all tasks). Related production trick: QLoRA fine-tunes a task adapter directly on the quantized base, killing two birds — task adaptation and quantization recovery — in one training run.
Chapter 6: Layer Streaming — Running Models Bigger Than Memory
The idea: a transformer executes layer-by-layer; only the current layer's weights must be resident. Keep weights in fast storage (NVMe/UFS flash), stream layer $i{+}1$ while computing layer $i$, evict behind you — a sliding window of 2–3 layers in RAM.
The governing inequality: streaming is free only if
$$t_{load}(L_{i+1}) \le t_{compute}(L_i) \quad\Leftrightarrow\quad \frac{\text{bytes}{layer}}{\text{storage BW}} \le t{compute}$$
For batch-1 decode, $t_{compute}$ per layer is tiny (it's bandwidth-bound already!) — so streaming from flash (~1–4 GB/s UFS) is typically 10–30× slower than RAM-resident decode. Honest uses: (1) prefill-heavy or batched workloads where compute per layer is large; (2) MoE models — stream only the (few) experts the router selected (the Phase 02 synergy: sparsity makes streaming viable); (3) burst memory pressure (the OS reclaimed your pages) handled gracefully instead of crashing; (4) pipelines across NPU+CPU where overlap hides part of the cost.
Engineering pieces (Lab 03): double-buffered async prefetch, pinned staging buffers, eviction policy (sequential for dense; router-driven for MoE), and the measurement-honesty part — report tok/s vs resident-set-size curves, not one cherry point.
Chapter 7: The Edge Serving Stack
How the pieces assemble on a phone (the capstone's mental model):
- Format: GGUF (llama.cpp) or vendor DLC (Phase 06) — both pack per-group quantized weights + tokenizer + metadata in one mmap-able file. mmap means the OS pages weights on demand and shares them across processes — instant warm starts.
- Runtime split: prefill on NPU (compute-bound, int-friendly); decode often lands on CPU+NPU hybrid because per-token kernel launch overhead and dequant-fused GEMV quality decide it — measure, don't assume.
- KV cache: INT8-quantized KV is standard on edge (Phase 02's budget halves); sliding-window or cache eviction for long chats.
- Thermals: sustained tok/s ≠ burst tok/s (Phase 06 Ch. 7); a chat UX needs sustained. Energy per token (joules) is a first-class metric — battery is the real SLO.
- The accuracy loop: every artifact above changed the numbers — the Phase 09 harness runs against the device (or its bit-exact simulator), not the FP16 checkpoint.
Lab Walkthrough Guidance
Order: Lab 01 (AWQ) → Lab 02 (QLoRA adapter) → Lab 03 (layer streaming).
- Lab 01: reuse Phase 03's quantize/dequantize core. Implement activation-magnitude collection → salient channel identification → the scale fold → grid-search α per layer against reconstruction error. Verify the scaled-then-quantized layer beats plain RTN on both reconstruction error and PPL, and that the $s$/$1/s$ fold is numerically invisible pre-quantization (identity test).
- Lab 02: implement the NF4 codebook (compute the 16 normal quantiles yourself and compare to the published table); block-wise absmax + double quant; then the LoRA module (test: $\Delta W=0$ at init); then recovery training with the KL-to-FP16 objective; measure the recovered-accuracy table FP16 / INT4 / INT4+adapter.
- Lab 03: build the layer-window manager with async prefetch; verify outputs are bit-identical to resident execution (correctness first); then measure tok/s vs window size and storage bandwidth, and reproduce Chapter 6's inequality empirically — find the crossover where streaming stops hurting.
Success Criteria
You are ready for Phase 11 when you can, from memory:
- Budget weights/KV/activations for any model on a given device and state the decode tok/s ceiling from bandwidth alone.
- Write the AWQ scaling identity, say where $1/s$ folds, and why scaling beats mixed-precision storage on NPUs.
- Write the LoRA update with init and the role of $\alpha/r$; state the merge-vs- separate tradeoff.
- Explain NF4 in one sentence (normal quantiles → equal probability mass per level) and what double quantization saves.
- Defend adapter-based quantization recovery vs QAT on cost and artifact-management grounds.
- State the streaming inequality and the three honest use cases.
Interview Q&A
Q: 7B model, phone with 8 GB RAM (3 GB usable), 60 GB/s. Deployment plan? INT4 g=128 weights ≈ 3.9 GB — doesn't fit usable RAM. Options in order: 3-bit (≈3 GB, accuracy risk — adapter recovery mandatory), a smaller base (3B INT4 ≈ 1.7 GB — usually the right product answer), or MoE-style streaming if the model were sparse. If 3.5 GB usable: INT4 + INT8 KV + mmap, expect ~60 GB/s × utilization / 3.9 GB ≈ 10–12 tok/s sustained ceiling; validate accuracy with the Phase 09 harness against FP16. The point of the answer: arithmetic first, then options, then accuracy validation.
Q: Why NF4 over INT4 for QLoRA's base, but INT4 for AWQ deployment? NF4 is statistically optimal for storage of normal-ish weights when you'll dequantize to float anyway (QLoRA's compute is BF16 — the LUT dequant is fine). NPU deployment wants integer arithmetic: uniform INT4 grids map to MAC hardware; NF4's lookup table doesn't. Storage-optimal ≠ compute-friendly — choose per execution model.
Q: Your INT4+adapter model matches FP16 on perplexity but users report worse coding. Perplexity is a distribution-level canary, not a capability metric (Phase 09 Ch. 2); recovery training distribution probably lacked code. Evaluate task suites stratified by domain, retrain the adapter with code-heavy distillation data, and add a code-eval gate to the regression CI. The deeper lesson: recovery is only as broad as its training distribution.
Q: When does layer streaming actually pay on edge? When per-layer compute time ≥ per-layer load time: prefill/batch (compute-heavy), MoE expert streaming (load 2 of 8 experts), or fast storage + slow compute combos. For batch-1 dense decode it's a 10×+ regression — say so, and propose the smaller-model or sparsity alternative instead. Knowing when not to use the clever technique is the senior signal.
References
- Lin et al., AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration (2023) — arXiv:2306.00978
- Hu et al., LoRA: Low-Rank Adaptation of Large Language Models (2021) — arXiv:2106.09685
- Dettmers et al., QLoRA: Efficient Finetuning of Quantized LLMs (2023) — arXiv:2305.14314
- Dettmers & Zettlemoyer, The case for 4-bit precision: k-bit Inference Scaling Laws (2022) — arXiv:2212.09720
- Alizadeh et al., LLM in a flash: Efficient LLM Inference with Limited Memory (2023) — arXiv:2312.11514 — the streaming reference
- llama.cpp — read the GGUF spec and the quantization kernels (
ggml-quants.c) - bitsandbytes NF4 implementation
- Frantar & Alistarh, Sparse-Quantized Representations (SpQR) (2023) — arXiv:2306.03078
The Hitchhiker's Guide — Phase 10: GenAI on Edge & Embedded Systems
Section 1: The Edge Memory Constraint — Why Everything Changes
A 7B LLM:
- FP32: 28 GB
- FP16/BF16: 14 GB
- INT8: 7 GB
- INT4: 3.5 GB
- INT2 (AQLM): ~1.75 GB
Snapdragon 8 Gen 3: 12 GB total DRAM shared with OS + all apps. Effective model budget: 2–4 GB.
This makes INT4 the minimum for 7B models on current flagship smartphones. For 13B+ models, you need layer streaming or INT2.
The Memory Bandwidth Bottleneck
At batch_size=1 (typical for mobile), LLM decode is memory-bandwidth-bound:
- Each decode step reads all model weights once: 3.5 GB for 7B INT4
- At 77 GB/s (Snapdragon DRAM): 3.5 GB / 77 GB/s = 45 ms/token → 22 tokens/sec
- HTP can't help much here — the bottleneck is reading weights from DRAM, not compute
This is why mobile LLM speed scales linearly with DRAM bandwidth — compute capacity doesn't matter.
Section 2: INT4 Quantization for Edge — Format Comparison
GPTQ (Generative Pre-Trained Quantization)
Based on second-order information (Hessian of the reconstruction error):
$$W_q = \arg\min_{W_q \in \mathcal{Q}} |W - W_q|{H}^2 = \arg\min{W_q} (W - W_q) H (W - W_q)^T$$
where $H$ is the input activation covariance matrix (Hessian proxy).
Algorithm (OBQ/GPTQ):
- Compute $H = X^T X$ from calibration data
- For each weight column $j$ in order:
- Quantize $w_j$ to nearest INT4 value
- Compute quantization error $\delta_j = w_j - \text{quantize}(w_j)$
- Propagate error to remaining columns: $w_{j'} \mathrel{-}= \delta_j \cdot H_{jj'}^{-1} H_{jj}$ for $j' > j$
Properties:
- Group size $g=128$ per row means each group of 128 weights shares a scale
- Best accuracy among PTQ INT4 methods for language models
- Quantization time: ~1 hour for 7B model on A100
AWQ (Activation-Aware Weight Quantization)
Observation: not all weights are equally important. Weights corresponding to large-magnitude activations ("salient channels") cause more quantization error.
AWQ algorithm:
- Find salient input channels: $\text{saliency}j = |X{:,j}|2 \cdot |W{j,:}|_2$ (activation × weight magnitude)
- For top-1% salient channels: scale UP weights before quantization, scale DOWN activations
- $W' = W \cdot \text{diag}(s)$, $X' = X \cdot \text{diag}(s)^{-1}$ where $s_j \propto \text{saliency}_j^{0.5}$
- The product $WX = W'X'$ is identical, but $W'$ is "flatter" (easier to quantize)
- Quantize $W'$ to INT4 with naive per-group scaling
Properties:
- No per-weight Hessian computation → fast (10 min for 7B vs 1 hour for GPTQ)
- Similar accuracy to GPTQ for most tasks
- Better for tasks sensitive to specific feature channels (e.g., math reasoning)
NF4 (Normal Float 4-bit)
Standard INT4 divides $[-\text{absmax}, \text{absmax}]$ into 16 equal intervals. But weight distributions are approximately Gaussian — the tails are rarely used.
NF4 places quantization levels at the quantiles of a standard normal distribution: $$q_i = \Phi^{-1}\left(\frac{i + 0.5}{2^b}\right), \quad i = 0, \ldots, 2^b - 1$$
This is information-theoretically optimal for normally distributed data — each quantization level covers the same probability mass.
# The 16 NF4 levels (precomputed from normal distribution quantiles):
NF4_LEVELS = [
-1.0, -0.6961928009986877, -0.5250730514526367, -0.39491748809814453,
-0.28444138169288635, -0.18477343022823334, -0.09105003625154495, 0.0,
0.07958029955625534, 0.16093020141124725, 0.24611230194568634, 0.33791524171829224,
0.44070982933044434, 0.5626170039176941, 0.7229568362236023, 1.0
]
NF4 is used by bitsandbytes in QLoRA — enables high-quality INT4 quantization of the base model while training LoRA adapters in BF16.
Section 3: LoRA Mathematics
The Low-Rank Hypothesis
Pre-trained model weight matrices $W_0 \in \mathbb{R}^{d \times k}$ have been shown to have low intrinsic dimensionality — the gradient updates $\Delta W$ during fine-tuning are approximately low-rank.
LoRA exploits this: instead of updating $W_0$ directly, we learn: $$W = W_0 + \Delta W = W_0 + BA$$
where $B \in \mathbb{R}^{d \times r}$, $A \in \mathbb{R}^{r \times k}$, and $r \ll \min(d, k)$.
Trainable parameters: $r(d + k)$ instead of $dk$ — for $d=k=4096$, $r=64$: 524,288 instead of 16,777,216 — 32× reduction.
Initialization: $A \sim \mathcal{N}(0, \sigma^2)$, $B = 0$ — ensures $\Delta W = 0$ at start (full model capability preserved).
Scaling: $\Delta W = \frac{\alpha}{r} BA$ where $\alpha$ is a hyperparameter (typically $\alpha = 2r$).
QLoRA: Quantized Base + FP16 Adapters
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model
# Load model in NF4 (INT4 quantized)
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NF4 quantization
bnb_4bit_compute_dtype=torch.bfloat16, # compute in BF16 (not quant)
bnb_4bit_use_double_quant=True, # quantize the scale factors too
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3.2-1B",
quantization_config=bnb_config,
device_map="auto",
)
# Prepare for training (needed for gradient checkpointing with quant models)
model = prepare_model_for_kbit_training(model)
# Add LoRA adapters (trained in BF16, base model stays in INT4)
lora_config = LoraConfig(
r=64,
lora_alpha=128,
target_modules=["q_proj", "v_proj", "k_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
lora_dropout=0.1,
bias="none",
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 41,943,040 || all params: 1,276,928,000 || trainable%: 3.28
Double quantization: bnb_4bit_use_double_quant=True quantizes the INT4 scale factors (which are FP32) with INT8 → additional 0.5 bits/weight savings.
LoRA Merging for Deployment
After training, merge LoRA weights back into the base model for efficient deployment (no adapter overhead at inference):
from peft import PeftModel
# Load base + adapters
base_model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.2-1B")
model = PeftModel.from_pretrained(base_model, "./lora_checkpoint")
# Merge
merged_model = model.merge_and_unload()
merged_model.save_pretrained("./merged_model")
Section 4: Memory-Constrained Inference Strategies
Layer Streaming with Prefetching
The key insight for layer streaming: GPU compute time for one layer > time to load next layer from NVMe.
Timeline (no prefetch):
[Load L1][Compute L1][Load L2][Compute L2][Load L3] ...
Timeline (with prefetch):
[Load L1][Compute L1 + Load L2][Compute L2 + Load L3] ...
↑ overlap load and compute
If $T_{compute} > T_{load}$: computation is the bottleneck → prefetching hides all load latency → same speed as fully-loaded inference.
For a 7B LLM INT4 per layer: $3500 \text{ MB} / 32 \text{ layers} \approx 109 \text{ MB/layer}$. At eMMC bandwidth 1 GB/s: 109 ms to load. At Snapdragon HTP: ~50 ms to compute per layer for batch=1. So streaming adds ~50% overhead even with prefetch (load is slower than compute).
Sliding Window Attention (Infinite Context, Bounded Memory)
Standard KV cache grows unboundedly with sequence length. Sliding window attention limits the KV cache to a fixed window of $W$ tokens:
def sliding_window_attention(q, k, v, window_size=512):
"""Only attend to the last window_size tokens."""
seq_len = q.shape[1]
# Create causal mask that also masks tokens outside the window
mask = torch.ones(seq_len, seq_len, dtype=torch.bool)
mask = torch.tril(mask) # causal
# Block tokens older than window_size
for i in range(seq_len):
if i >= window_size:
mask[i, :i - window_size] = False
return scaled_dot_product_attention(q, k, v, attn_mask=~mask)
StreamingLLM extension: keep "attention sinks" (first 4 tokens, which accumulate high attention weights) + sliding window. Preserves positional encoding stability and prevents output degradation in very long sequences.
Section 5: Interview Pitfalls
| Question | Wrong Answer | Right Answer |
|---|---|---|
| "Why does mobile LLM performance scale with DRAM bandwidth not TOPS?" | "It doesn't" | "At batch_size=1 (typical mobile), decode is memory-bound: each step reads all weights once; FLOP count is trivial compared to data movement. Doubling DRAM bandwidth → 2× decode speed; doubling TOPS → no benefit." |
| "What is NF4 and why is it better than INT4 for LLM weights?" | "It's a 4-bit format" | "NF4 places quantization levels at normal distribution quantiles; this is information-theoretically optimal for weights with Gaussian distributions; ~0.5 PPL better than INT4 at same bit-budget" |
| "What LoRA rank should I use?" | "64 is standard" | "Depends on task complexity and available compute. r=8 for simple instruction-following, r=64 for complex domain adaptation, r=128 for hard tasks. Always sweep r and plot accuracy vs trainable params — there's typically an elbow." |
| "How do you deploy a LoRA adapter to mobile?" | "Use PEFT on device" | "Merge LoRA into base model (merge_and_unload()), then export the merged model to ONNX/QNN. PEFT adds inference overhead; merge for production unless you need hot-swappable adapters." |
Section 6: Resources
- QLoRA paper — Dettmers et al., "QLoRA: Efficient Finetuning of Quantized LLMs" (NeurIPS 2023)
- LoRA paper — Hu et al., "LoRA: Low-Rank Adaptation of Large Language Models" (ICLR 2022)
- GPTQ paper — Frantar et al., "GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers" (ICLR 2023)
- AWQ paper — Lin et al., "AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration" (MLSys 2024)
- StreamingLLM — Xiao et al., "Efficient Streaming Language Models with Attention Sinks" (ICLR 2024)
- bitsandbytes library — Tim Dettmers — https://github.com/TimDettmers/bitsandbytes
- llama.cpp — https://github.com/ggerganov/llama.cpp — reference implementation for edge LLM inference
- MLC LLM — https://github.com/mlc-ai/mlc-llm — TVM-based mobile LLM deployment
- Qualcomm AI Hub LLM models — https://aihub.qualcomm.com/mobile-accelerated/llm — official Snapdragon-optimized LLMs
👨🏻 Brother Talk — Phase 10, Off the Record
GenAI on edge — the JD's headline: "latest GenAI techniques and adapt those for NPUs." This is the frontier, the reason this role exists right now, and the hardest constraint set.
Brother, this is the phase — the one the JD leads with, the reason Qualcomm is hiring Senior Staff ML engineers in 2026. Running a multi-billion-parameter generative model on a phone — on a battery, with a few gigabytes of memory, with no datacenter to fall back on — is one of the hardest problems in applied ML right now, and the people who can do it are vanishingly rare. Every technique you've learned converges here, under the most brutal constraints in the whole field. If you nail this phase, you're not interviewing for the job; the job is interviewing for you.
The constraint that dominates everything: memory. A 7B model in FP16 is ~14GB — that doesn't fit on a phone, full stop. So edge GenAI is, first and foremost, a fit the model in memory problem, and that's why weight-only quantization to 4-bit (and below) is non-negotiable here. AWQ (the lab) is the technique to internalize: Activation-aware Weight Quantization. Its insight is that not all weights matter equally — a small fraction of "salient" weight channels (identified by the activation magnitudes, not the weights themselves) carry most of the accuracy. Protect those (keep them high-precision or scale them cleverly), crush the rest to 4-bit, and you hold accuracy where naive quantization collapses. AWQ vs GPTQ (P03) is a great interview compare-and-contrast: GPTQ uses second-order error compensation, AWQ uses activation-aware scaling — both fight the same battle (4-bit weights without accuracy death) from different angles.
QLoRA (the lab) is the technique that makes on-device adaptation possible, and it's gorgeous: keep the base model frozen in 4-bit (NF4 — a quantization tuned for the normal distribution of neural weights), and train only tiny low-rank adapters in higher precision. You get task customization without the memory of full fine-tuning, and you can swap adapters per task. On edge, this is how one quantized base model serves many personalized behaviors. Understand why the base can be 4-bit while the adapters are 16-bit — the adapters carry the delta, which is small and sensitive, while the frozen base carries the bulk, which tolerates quantization.
Layer streaming (the lab) is the desperate-but-necessary trick for when the model still doesn't fit: stream weights layer-by-layer from storage/DRAM into the NPU's working memory, compute, evict, load the next. It trades latency (you're I/O-bound on weight loading — the roofline strikes again, P07) for the ability to run a model bigger than your memory. It's a last resort with a real cost, and knowing when it's worth it (and when to just use a smaller model) is the judgment.
The thing nobody tells you about edge GenAI: the KV cache is the silent memory killer, not just the weights. Everyone obsesses over quantizing the weights and forgets that the KV cache grows with sequence length and can dwarf the model at long context. On a memory-starved device, you quantize the KV cache too, you bound the context, you page it. The candidate who only thinks about weight quantization and forgets KV-cache memory will be surprised in production when a long conversation OOMs the phone. Think about total memory across the whole inference, not just the weights at rest.
This is also where the whole stack you've built comes together, and you should feel it: you take a model (P02), quantize it AWQ-style (P03/P10), compile and lower it (P04/P05), deploy it to the NPU with high coverage (P06), profile it to find the memory/latency bottleneck (P07), apply FlashAttention + speculative decoding adapted for batch-1 edge (P08), and prove the accuracy held under all of it (P09). Edge GenAI is the integration exam. The Senior Staff engineer is the one who holds all of it at once and makes the right tradeoff at each layer for this device's power and memory budget.
And the co-design payoff (the JD: "architect new techniques which work best on Qualcomm SOCs"): the real frontier isn't applying known techniques — it's inventing the variant that fits the hardware. The published AWQ/QLoRA assume certain hardware; the Senior Staff contribution is "here's the modification that maps to our NPU's fixed-point units and memory hierarchy." That's research
- engineering + hardware fluency in one move, and it's the literal top of this role.
Career truth, brother: edge GenAI is where the demand and the scarcity are highest right now. Datacenter GenAI has thousands of capable engineers; on-device GenAI under real power/memory constraints has very few, because it requires the rare full-stack-plus-hardware combination this whole track builds. If you can credibly say "I can put a 7B model on a phone, hold accuracy at 4-bit, fit it in memory, and adapt it per task" — you are at the absolute center of where the field and the market are heading. This is the phase to dream big and go deep.
Build AWQ. Build QLoRA. Think about the KV cache, not just the weights. Then come to P11 — the capstone, where you compose everything into the systems a Senior Staff engineer actually ships and defends in design reviews.
— your brother 👨🏻
Lab 01 — AWQ Quantization
Phase: 10 — GenAI on Edge | Difficulty: ⭐⭐⭐⭐⭐ | Time: 3–4 hours
AWQ (Activation-aware Weight Quantization, Lin et al., 2023) is state-of-the-art INT4 quantization for LLMs deployed on edge devices.
What you build
compute_per_channel_scale— max|W[i,:]| / 7 for INT4 symmetric quantizationquantize_weight_int4— clamp[-7,7] → round → dequantizeawq_scale_search— grid search over α∈[0,1]: s = x_max^α, minimize ‖W@X - Q(W·s)/s@X‖²awq_quantize_linear— full AWQ quantization of a single linear layerevaluate_quantization_error— relative error + cosine similarity
Key concepts
| Concept | What to understand |
|---|---|
| Channel scaling | Multiply salient weight channels by s before quantization (reduces error) |
| Activation-aware | s = x_max^α — scale determined by activation magnitude, not just weights |
| INT4 vs INT8 | INT4 = 2× more layers fit in NPU SRAM — critical for 7B+ edge LLMs |
| AWQ vs GPTQ | AWQ: activation scaling; GPTQ: Hessian-based correction — both SOTA |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Lab 02 — QLoRA Adapter
Phase: 10 — GenAI on Edge | Difficulty: ⭐⭐⭐⭐⭐ | Time: 4–5 hours
QLoRA (Dettmers et al., 2023) fine-tunes 65B LLMs on a single 48GB GPU using 4-bit NF4 quantization + LoRA adapters.
What you build
LoRALinear— frozen base weight + trainable low-rank adapters A, B (B=0 init)quantize_nf4/dequantize_nf4— NF4 (Normal Float 4-bit) block quantization (block_size=64)QLoRALinear— NF4 quantized base + trainable LoRA adaptertrain_qlora— freeze base, train A/B/bias only with AdamWmerge_lora— W_merged = W_base_deq + B@A * (α/r) for deployment
Key concepts
| Concept | What to understand |
|---|---|
| LoRA | W = W_base + B@A * (α/r); rank r << d — few trainable params |
| NF4 | Quantization grid optimized for normally-distributed weights |
| Block quantization | Independent scale per 64 values — better accuracy than per-tensor |
| Double quant | Quantize the quantization constants themselves (saves ~0.4 bpw) |
| Memory savings | 7B model: FP16=14GB, INT8=7GB, NF4=3.5GB VRAM |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Lab 03 — Layer Streaming for Edge LLMs
Phase: 10 — GenAI on Edge | Difficulty: ⭐⭐⭐⭐⭐ | Time: 3–4 hours
Run 7B+ LLMs on devices with 4GB RAM by streaming layers from flash storage into a small device memory window.
What you build
estimate_layer_bytes— compute memory footprint of a transformer layerLayerPool— device-side LRU buffer holding at most W layers at onceStreamingTransformer— prefetch next layer while running current; LRU evictionbenchmark_streaming— measure latency and peak memory for different window sizesselect_max_layers_for_budget— choose largest window fitting memory budget
Key concepts
| Concept | What to understand |
|---|---|
| Layer streaming | Load layer to GPU/NPU, run, evict — trade latency for memory |
| Prefetching | DMA next layer while computing current — hide transfer latency |
| Window size | Tradeoff: more layers cached = faster inference but more DRAM |
| LRU policy | For transformer sequential access, LRU ≈ FIFO |
| Flash bandwidth | NVMe: ~7 GB/s; eMMC (phone): ~300 MB/s — bottleneck at small W |
Files
| File | Purpose |
|---|---|
lab.py | Stubs with TODO markers |
solution.py | Complete working implementation |
test_lab.py | pytest suite |
requirements.txt | Dependencies |
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Phase 11 — Capstone Projects
Difficulty: ⭐⭐⭐⭐⭐
Estimated Time: 4 weeks (80–120 hours)
Roles Supported: Every capstone is a portfolio-ready demonstration of Senior Staff competency; select 2–3 based on target role
Why This Phase Exists
Interviews for Senior Staff engineers have a recurring pattern: "Tell me about the most technically complex system you've built." The capstone projects exist to give you a truthful, impressive, technically deep answer. Each capstone integrates skills from multiple phases into a coherent system.
More importantly: the capstone projects are designed to be continued. After your first job, these are systems you can extend, benchmark against new hardware, and publish. They become long-term portfolio projects, not throw-away exercises.
Capstone Selection Guide
| Capstone | Target Role | Key Skills Demonstrated | Time |
|---|---|---|---|
| 01 — Accuracy Regression CI System | Any ML engineering | Eval harness, statistical testing, CI/CD, regression detection | 2 weeks |
| 02 — Full NPU Inference Pipeline | Qualcomm / Edge AI | PyTorch → ONNX → QNN → HTP profiling → accuracy delta | 3 weeks |
03 — Custom torch.compile Backend | Framework engineering | FX passes, compiler backend, mock ISA, code generation | 3 weeks |
| 04 — Production Quantization Suite | ML infrastructure | PTQ + QAT + GPTQ + AWQ comparison, accuracy recovery | 2 weeks |
| 05 — Multi-Model Eval + Pareto Dashboard | Platform engineering | Eval pipeline, Pareto analysis, interactive dashboard, CI | 2 weeks |
Capstone 01 — Production Accuracy Regression CI System
Overview
A production-ready GitHub Actions workflow that:
- Runs eval harness (MMLU, HellaSwag, PPL) on a PR's model checkpoint
- Compares to the main-branch baseline using McNemar's test
- Fails the PR if any task drops >0.5% (customizable threshold) with p<0.05
- Posts a detailed report as a PR comment with per-task accuracy, confidence intervals, and Pareto position
Architecture
PR triggered →
GitHub Actions: checkout model checkpoint
→ run eval harness (200 examples per task, ~5 min)
→ load baseline from artifact store
→ apply McNemar's test per task
→ post Markdown report as PR comment
→ PASS/FAIL based on policy
What to Build
eval/eval_harness.py— from Phase 09 Lab 01eval/regression_check.py— McNemar's test + policy engineeval/report_generator.py— Markdown report with tables and badges.github/workflows/accuracy-ci.yml— full CI pipelineeval/baselines/— artifact store for baseline resultstests/test_regression_check.py— property-based tests for regression logic
Deliverables
- Functional GitHub Actions CI that blocks PRs with accuracy regressions
- Demo: commit a model with injected 2% MMLU regression → CI blocks it; report shows which examples changed
- README with architecture diagram, configuration guide, and threshold selection rationale
Resume Bullet
Built production accuracy regression CI system used to guard model quality across 50+ optimization PRs; system blocked 7 regressions in 3 months, preventing customer-facing quality degradation
Capstone 02 — Full NPU Inference Pipeline
Overview
End-to-end pipeline from PyTorch training checkpoint to profiled, optimized inference on Qualcomm HTP:
- Export to ONNX with full opset validation
- Optimize ONNX graph (fold constants, eliminate redundancies)
- Convert to Qualcomm AI Hub DLC format
- Profile HTP execution, identify bottleneck layers
- Quantize to INT8/INT4, measure accuracy delta, iterate
Architecture
PyTorch FP16 checkpoint
→ torch.export (FX-based)
→ ONNX opset 18
→ onnxoptimizer + onnxsim
→ Qualcomm AI Hub: compile for Snapdragon 8 Gen 3
→ HTP execution + layer-level profiling
→ Identify top-3 bottleneck ops
→ INT8 quantization → accuracy delta
→ INT4 quantization → accuracy delta
→ Pareto: accuracy vs latency per config
What to Build
pipeline/export.py— PyTorch → ONNX with shape validationpipeline/optimize.py— ONNX graph optimization passespipeline/qai_submit.py— AI Hub submission + result pollingpipeline/profile_analyzer.py— parse HTP profile, identify bottleneckspipeline/quantize.py— INT8/INT4 pipeline with calibrationpipeline/accuracy_delta.py— accuracy delta measurement at each stagepipeline/run_pipeline.sh— single-command E2E run
Models to Run
- MobileNetV3 (classification baseline)
- Whisper-tiny (audio, tests decoder with cross-attention)
- LLaMA-3.2-1B (LLM, tests token generation pipeline)
Deliverables
- Single-command pipeline that produces accuracy + latency + memory report for any HuggingFace model
- HTP profiling report showing per-layer timing for LLaMA-3.2-1B decode step
- Pareto frontier plot: accuracy vs latency for 5 quantization configs per model
- README with deployment instructions for each model
Resume Bullet
Built end-to-end NPU inference pipeline (PyTorch → ONNX → QNN → HTP profiling) for 3 model families; identified and resolved 2 HTP-incompatible ops; LLaMA-3.2-1B achieved 14.2 tok/s at INT4 with 97.3% of FP16 MMLU accuracy
Capstone 03 — Custom torch.compile Backend for Mock NPU ISA
Overview
Implement a custom torch.compile backend that lowers FX IR to a fictional but realistic NPU instruction set (inspired by Qualcomm's QNN op set). This demonstrates deep compiler knowledge — the most differentiating skill for this role.
Mock NPU ISA
Define 15 instructions:
MATMUL,CONV2D,DEPTHWISE_CONV2D— compute opsLOAD_TILE,STORE_TILE— memory ops (DRAM ↔ VTCM)QUANTIZE,DEQUANTIZE— precision opsRELU,GELU,SOFTMAX,LAYERNORM— activation/norm opsELEMENTWISE_ADD,ELEMENTWISE_MUL,BROADCAST— elementwiseCONCAT,RESHAPE— shape ops
What to Build
npu_backend/isa.py— instruction set dataclasses + validatornpu_backend/lowering.py— FX node → ISA instruction mappernpu_backend/fusion.py— graph optimization: fuse MATMUL + RELU, LAYERNORM + DROPOUT, etc.npu_backend/codegen.py— ISA instruction list → JSON "NPU program" + pretty-printernpu_backend/simulator.py— simulate execution: count cycles, memory traffic, VTCM pressurenpu_backend/register_backend.py— register astorch.compile(backend="mock_npu")tests/— test every lowering rule + fusion pass
What it Produces
model = nn.Sequential(nn.Linear(512, 512), nn.ReLU())
compiled = torch.compile(model, backend="mock_npu")
output = compiled(x) # runs PyTorch for correctness
# Export NPU program:
program = get_npu_program(model, x)
print(program)
# NPU_PROGRAM:
# LOAD_TILE weight[512,512] → VTCM
# MATMUL in[B,512] × VTCM[512,512] → out[B,512]
# RELU out[B,512] → out[B,512] (fused with MATMUL)
# STORE_TILE out[B,512] → DRAM
# Estimated cycles: 2,359,296
# VTCM peak: 1.0 MB / 8 MB capacity
Deliverables
- Fully functional
torch.compilebackend that handles MLP and Conv models - Fusion pass that correctly fuses
MATMUL+RELU,MATMUL+GELU,LAYERNORM+ADD - Cycle estimator with validation against a hand-calculated reference
- Blog-post-quality README explaining the architecture
Resume Bullet
Implemented custom torch.compile backend targeting mock NPU ISA; registered 15 lowering rules, built 6 fusion passes; backend correctly compiles and simulates MLP/Conv/Transformer ops with cycle estimation within 5% of hand-calculated reference
Capstone 04 — Production Quantization Comparison Suite
Overview
Comprehensive comparison of all major post-training quantization methods on 3 model families, with automated accuracy recovery, and a final recommendation report — the kind of analysis a Senior Staff engineer would deliver to an architecture review board.
Methods Compared
| Method | Bits | Calibration | Target |
|---|---|---|---|
| Naive MinMax | INT8 | 128 samples | Baseline |
| Percentile (99.9%) | INT8 | 128 samples | Better tails |
| GPTQ | INT4 | 128 samples | Memory-efficient |
| AWQ | INT4 | 128 samples | Salient channels |
| SmoothQuant | INT8 | 128 samples | LLM-specific |
| QAT (simulated) | INT8 | Full fine-tune | Best quality |
Models
- ResNet-50 (ImageNet top-1 accuracy)
- BERT-base (GLUE benchmark)
- LLaMA-3.2-1B (MMLU, PPL)
Deliverables
- Automated pipeline that produces full comparison table for any model
- Accuracy vs latency Pareto plot for all methods × models
- Recommendation engine: input (model_type, accuracy_floor, latency_target) → output (best method + config)
- Technical report (10-page PDF generated from Markdown): methodology, results, recommendations
Resume Bullet
Conducted systematic evaluation of 6 quantization methods across 3 model families; published comparison showing AWQ outperforms GPTQ by 0.9% MMLU at identical bit-width for LLM workloads; results used to standardize quantization methodology for production deployment
Capstone 05 — Multi-Model Eval + Pareto Dashboard
Overview
A web-based dashboard for tracking model accuracy and performance across multiple checkpoints over time: evaluates models, stores results in SQLite, displays interactive Pareto frontiers, and sends alerts when accuracy regresses.
What to Build
server/eval_worker.py— async eval worker (SQLite-backed job queue)server/api.py— FastAPI REST API: submit eval job, query results, get Paretofrontend/dashboard.html— interactive Plotly.js dashboard (no build system, single file)server/alert_engine.py— detect regressions, send GitHub notificationsserver/db.py— SQLite schema: models, evals, tasks, metrics
Demo Scenario
Submit 10 model checkpoints (from different quantization steps of LLaMA-3.2-1B) → dashboard shows accuracy degrading as quantization gets more aggressive → Pareto frontier identifies the INT8 SmoothQuant configuration as the optimal trade-off → CI webhook fires alert when INT4 variant drops below accuracy floor.
Resume Bullet
Built multi-model eval tracking dashboard (FastAPI + SQLite + Plotly.js); tracks 10+ metrics across model checkpoints; powers quantization decisions for 3 product teams; identified optimal deployment config saving 40% DRAM while staying within accuracy requirements
Recommended Completion Order
- Capstone 01 first — gets CI/CD infrastructure in place for all other projects
- Capstone 04 — builds on phases 02-03, most directly reuses lab code
- Capstone 02 — requires QAI Hub access, do after account setup
- Capstone 03 — hardest; do this when you're comfortable with FX and torch.compile
- Capstone 05 — integrates everything; do last
Portfolio Presentation
For each completed capstone:
- GitHub repo with clean README, architecture diagram, and demo GIF
- One-paragraph LinkedIn post linking to the repo
- One 5-minute demo video (Loom) showing the tool in action
- One conference submission (NeurIPS Demo, MLSys, OSDI) if the work is strong
The capstones are designed to be real, useful tools — not exercises. After you've built them, keep using them. The best way to deepen your understanding is to encounter real problems with the tools you built.
Warmup Guide — Capstone
Orientation for Phase 11. The capstones contain no new theory — they test whether Phases 01–10 compose. This warmup covers the integration skills the labs assume: pipeline architecture, configuration discipline, debugging across stage boundaries, and turning capstones into portfolio artifacts.
Table of Contents
- Chapter 1: What Changes at the Capstone Level
- Chapter 2: Pipeline Architecture — Stages, Artifacts, Gates
- Chapter 3: Configuration and Reproducibility Discipline
- Chapter 4: Cross-Stage Debugging — The Bisection Discipline
- Chapter 5: The Five Capstones and What Each Certifies
- Chapter 6: From Capstone to Portfolio
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: What Changes at the Capstone Level
A lab asks "can you implement X?"; a capstone asks "can you make X, Y, and Z work together and prove it with numbers?" Three shifts:
- Interfaces over implementations: each prior phase's deliverable becomes a component with a contract (the quantizer takes a model + calibration data, returns a model + a report). Capstone failures are nearly always at interfaces — implicit assumptions (layout, dtype, tokenizer version) crossing a boundary uninspected.
- Numbers over claims: every capstone deliverable is a table or plot — accuracy before/after, latency distributions, Pareto fronts. "It works" is not a result.
- Failure handling over happy path: pipelines run unattended (CI, sweeps); a stage that crashes without a diagnostic artifact wastes a night of compute. Production thinking starts here.
Chapter 2: Pipeline Architecture — Stages, Artifacts, Gates
The architecture all five capstones share (and that real deployment pipelines — Phase 06 Ch. 4 — share too):
[stage] → artifact + report → [gate] → [stage] → ...
- Stages are pure-ish functions of (input artifact, config): exportable, re-runnable, individually testable. No stage reaches into another's internals.
- Artifacts are files (ONNX, quantized checkpoint, eval JSON, profile trace) with content-addressed or versioned names. If a stage's output isn't a savable artifact, you can't cache, diff, or bisect it.
- Reports travel with artifacts: every stage emits machine-readable metrics (node counts, accuracy, latency) — the regression CI (capstone 01) is just gates over these reports.
- Gates are explicit: numeric thresholds with statistical backing (Phase 09 Ch. 8), not eyeballs. A pipeline without gates silently ships regressions; a pipeline with uncalibrated gates cries wolf until ignored.
This shape — not any specific tool — is what "production quantization pipeline" (capstone 04) means.
Chapter 3: Configuration and Reproducibility Discipline
Sweeps (capstone 04/05 run dozens of configs) die without discipline:
- One config object per run, serialized alongside artifacts. Every knob — bit-width, group size, calibration set hash, seed, library versions — in the config, nothing ambient. The test: a colleague reruns from the config file alone and matches your numbers.
- Seeds are config: calibration sampling, eval subset selection, generation — all seeded. Run-to-run σ measured once (Phase 09 Ch. 8) and quoted with every comparison.
- Naming:
{model}-{method}-{bits}g{group}-{calib_hash[:8]}beatsfinal_v2_fixed. Six weeks later, the filename is the documentation. - The results table is append-only: a CSV/JSON-lines ledger every run appends to — Pareto plots (capstone 05) regenerate from the ledger, never from memory.
Chapter 4: Cross-Stage Debugging — The Bisection Discipline
The capstone debugging situation: end-to-end accuracy is wrong, five stages could be responsible. The procedure (generalizing Phase 05 Ch. 4 and Phase 06's pipeline):
- Freeze inputs: one fixed batch, fixed seed, through every stage.
- Bisect over stages: compare each stage's output against the previous stage's under the same inputs (PyTorch vs ONNX, ONNX vs quantized, simulator vs device). The first diverging stage owns the bug.
- Bisect within the stage — per layer: dump intermediate activations (hooks on one side, per-node outputs on the other); find the first diverging layer. Divergence metrics: max-abs and cosine — max-abs catches outliers, cosine catches systematic rotation; report both.
- Distinguish expected from broken divergence: quantization legitimately diverges (bounded by step size, Phase 03 Ch. 3); a growing-with-depth divergence is error accumulation; a step-function divergence at one layer is a bug in that layer's transform.
- Convert every found bug into a gate: the regression suite grows from the pipeline's own pathology. That feedback loop is the difference between a script and infrastructure.
Chapter 5: The Five Capstones and What Each Certifies
| Capstone | Composes | Certifies you can… |
|---|---|---|
| 01 Regression CI | Phase 09 harness + stats + CI | make accuracy a gated property of a codebase |
| 02 NPU graph optimization | Phases 04–06 (FX passes, partitioning, NPU constraints) | optimize a graph for a target and prove the win on-profile |
| 03 Custom compile backend | Phase 04 backend + Phase 05 lowering | own the capture→partition→codegen path end to end |
| 04 Production quant suite | Phase 03 + 10 methods under one interface | run a method-vs-method bake-off with config discipline |
| 05 Multi-model dashboard | Phase 09 Pareto + the ledger | turn 50 runs into one decision-ready artifact |
Do them in an order that matches your target role's emphasis: NPU-focused → 02, 03 first; accuracy/eval-focused → 01, 05 first; everyone finishes with 04 (it forces the most integration).
Chapter 6: From Capstone to Portfolio
These capstones are interview ammunition only if they are legible:
- README with the money table first: the result (accuracy/latency table, Pareto plot) above the fold; how-to-run second; design notes third.
- One honest limitation paragraph each — "the NPU codegen targets a mock ISA; real-target port would need X" reads as senior; silence reads as not knowing.
- A 90-second demo path:
make demoor one notebook that runs on CPU and produces the headline artifact. Interviewers will not install CUDA for you. - Resume bullets follow the labs' format: implemented X composing Y, measured Z. Numbers from your ledger, ranges honest.
Lab Walkthrough Guidance
Each capstone's README carries its own steps; the cross-cutting advice:
- Read the target capstone's upstream labs' solutions first — the capstone assumes their interfaces; re-deriving them mid-capstone doubles the time.
- Build the walking skeleton before any depth: all stages connected with trivial implementations and one artifact flowing end-to-end, then deepen stages. Integration risk dies first; the demo exists from day one.
- Write the gate tests before the optimization (capstones 01, 04): a known-bad injection that must fail, a known-good baseline that must pass — then develop inside that harness.
- Keep the ledger from run #1 (Ch. 3) — retrofitting reproducibility is 10× the cost of starting with it.
Success Criteria
The capstones are done when:
- Each produces its headline artifact from a single entry point, reproducibly from config.
- The regression CI (01) demonstrably catches an injected 2% degradation and passes a clean change.
- The graph-optimization capstone (02) shows a before/after profile with the win attributed to named passes.
- The backend (03) compiles a LLaMA-style block with >95% op coverage and falls back gracefully on the rest.
- The quant suite (04) reproduces the Phase 03/10 method ranking on at least one model, from one CLI.
- The dashboard (05) renders the ledger as a Pareto front a non-engineer could choose from.
- Every repo passes the 90-second demo test on a clean machine.
Interview Q&A
Q: Walk me through your pipeline's design. Why stages and artifacts? Cacheability (re-run only what changed), bisectability (Ch. 4's procedure requires inspectable boundaries), testability (each stage has contract tests), and parallelism (sweeps fan out per config). The alternative — one script with globals — works once and debugs never.
Q: How do you know your pipeline's numbers are right? Three layers: harness validated against an external reference (Phase 09's 0.5% reconciliation); golden-input regression tests on every stage; and the injection test — the pipeline has demonstrably caught a planted error. Numbers from an unvalidated pipeline are decoration.
Q: You ran 60 configs; the best one looks too good. What now? Multiple-comparisons suspicion (Phase 09 Ch. 6): with 60 draws, the max is biased upward. Re-run the winner with fresh seeds and a held-out eval subset; check its neighbors in config space (a real effect has a smooth neighborhood, a fluke is isolated); only then promote it to the baseline.
Q: What would you do differently with another month? Have a real answer per capstone — the honest-limitation paragraphs (Ch. 6) are this question pre-answered. Generic humility scores zero; "the backend's memory planner is first-fit, and fragmentation costs ~8% TCM utilization on the LLaMA block — I'd implement offset-based planning next" scores.
References
- Hidden Technical Debt in Machine Learning Systems (NeurIPS 2015) — why pipelines rot
- Sculley et al., ML: The High-Interest Credit Card of Technical Debt (2014)
- MLflow tracking concepts — the ledger pattern, industrialized
- DVC — artifact versioning patterns worth stealing even without the tool
- The Pragmatic Programmer — tracer bullets (= walking skeleton)
- Phases 01–10 WARMUP and HITCHHIKERS guides — the actual prerequisite list
Phase 11 — Capstone: End-to-End AI Performance Engineering
Hitchhiker's Guide
"The answer to the ultimate question of life, the universe, and everything is 42 — but in ML, the answer to latency is 'it depends on your quantization scheme.'"
What This Phase Is
You have spent ten phases absorbing the complete toolchain of a Qualcomm Senior Staff ML Engineer — from transformer architecture internals to NPU graph optimization, from McNemar's regression tests to speculative decoding. Phase 11 is where you build production-grade systems that combine everything.
Each capstone lab simulates a real deliverable that a Senior Staff Engineer at Qualcomm might own end-to-end.
The Five Capstones
Lab 01 — Accuracy Regression CI Pipeline
You own: The automated accuracy regression gate that runs on every model commit.
Real-world context: At Qualcomm, every quantized model must pass accuracy gates before shipping to device OEMs. A regression in WER (speech), BLEU (translation), or accuracy (vision/NLP) can trigger customer escalations. You build the entire pipeline: baseline management, McNemar's test, Pareto analysis, GitHub Actions integration.
Skills combined: Phase 09 (eval at scale), Phase 07 (profiling), Phase 04 (quantization accuracy).
Lab 02 — NPU Graph Optimization Pipeline
You own: The torch.compile → ONNX → QNN compilation pipeline with accuracy verification.
Real-world context: A model trained in PyTorch must be compiled for the Hexagon NPU via ONNX → SNPE/QNN path. You own the graph analysis (unsupported ops detection), optimization passes (operator fusion, constant folding), and the accuracy gate that ensures <1% degradation after compilation.
Skills combined: Phase 05 (ML compilers), Phase 06 (Qualcomm NPU), Phase 03 (quantization).
Lab 03 — Custom torch.compile Backend
You own: A minimal torch.compile backend that inserts custom NPU operator calls.
Real-world context: At Qualcomm AI Research, ML engineers write custom compiler backends to target the Hexagon DSP. You implement the backend callable interface, register custom operators, and verify correctness with numerical tolerance tests.
Skills combined: Phase 05 (ML compilers / FX graphs), Phase 02 (model architecture), Phase 08 (inference).
Lab 04 — Production Quantization Suite
You own: A unified quantization pipeline: PTQ → sensitivity analysis → mixed-precision selection → QAT → deployment.
Real-world context: A model arrives from research team. You must deliver an INT4/INT8 mixed-precision version with <2% accuracy drop. You run ADAROUND, measure per-layer sensitivity, select a mixed-precision assignment, run QAT fine-tuning, and export.
Skills combined: Phase 03 (quantization), Phase 04 (post-training quant depth), Phase 06 (hardware-aware quant), Phase 10 (AWQ).
Lab 05 — Multi-Model Eval Dashboard
You own: A benchmarking harness that evaluates multiple model variants across tasks and generates a Pareto analysis report.
Real-world context: You receive 8 model variants (different quantization levels, architectures). You run them through 5 benchmark tasks, collect accuracy + latency, compute the Pareto front, detect regressions vs baseline, and generate a JSON report that feeds the CI system.
Skills combined: Phase 09 (eval at scale), Phase 07 (profiling), Phase 08 (inference), Phase 04 (benchmarking infra).
Interview Scenarios Each Lab Prepares You For
| Lab | Likely Interview Question |
|---|---|
| 01 | "How do you ensure a quantized model hasn't regressed before shipping to OEM?" |
| 02 | "Walk me through your ONNX export debugging process for a model with unsupported ops." |
| 03 | "How would you implement a custom operator that runs on Hexagon HTP in a torch.compile pipeline?" |
| 04 | "A model has 3% accuracy drop after INT8 quantization. What's your recovery strategy?" |
| 05 | "You have 8 model variants and 3 days. How do you decide which to ship?" |
Suggested Completion Order
- Lab 01 (CI Pipeline) — builds on Phase 09, standalone
- Lab 05 (Multi-Model Dashboard) — builds on Lab 01
- Lab 02 (NPU Graph Optimization) — builds on Phase 05/06
- Lab 04 (Production Quant Suite) — builds on Phase 03/04
- Lab 03 (Custom Backend) — most advanced, builds on all phases
Time Estimate
Each lab: 3–5 hours of focused work if you've done all prior phases. Total: 15–25 hours for the complete capstone.
👨🏻 Brother Talk — Phase 11, The Graduation
The capstone — where eleven phases become the systems a Senior Staff engineer actually ships and defends. You made it. Let me tell you what you've become and how to carry it.
Brother, look back at where you started: a job description that listed PyTorch internals, TensorFlow, model architecture, torch.compile, quantization, ML compilers, NPU hardware, profiling, inference optimization, evaluation, and edge GenAI — and probably felt like ten specialists' worth of knowledge crammed into one role. Now you've built a working miniature of every one of those, and you've seen the thing that the list hides: it's not ten separate skills, it's one pipeline seen from ten angles. A model is born (P02), made small (P03), compiled (P04/P05), placed on silicon (P06), measured (P07), accelerated (P08), proven (P09), and shipped to the edge (P10) — and the framework internals (P01) are the bedrock under all of it. The capstone is where you stop seeing phases and start seeing the system.
That's the real graduation: not "I know quantization" but "I can take a model from research to a phone and defend every decision along the way." The capstone labs are deliberately the seams — the regression CI pipeline (P09+CI), the NPU graph optimization (P04+P05+P06), the custom compile backend (P04+P06), the production quant suite (P03+P09), the multi-model dashboard (P09+P10). Each one forces you to make components that worked in isolation work together, which is where real engineering lives and where the Senior Staff title is actually earned. Anyone can pass a single phase; composing them under real constraints is the job.
Here's what I most want you to carry out of this track, the through-line of the whole role: accuracy and performance are not separate goals — they are a single tradeoff you manage with measurement. Every phase was secretly about the same thing: how do I make this faster/smaller without breaking it, and how do I prove I didn't break it? That's "Model Accuracy and AI Performance" as a worldview. The engineers who optimize blindly ship regressions; the engineers who refuse to optimize ship slow models; the Senior Staff engineer holds both in tension, moves on the Pareto frontier deliberately, and backs every move with a number. That judgment — not any single technique — is what you've been building.
On the "individual contributor with broad technical scope and mentorship" framing in the JD: the capstone is also where you practice the other half of Senior Staff — communication. These systems aren't just built, they're presented — at design reviews, to hardware teams, to leadership deciding which model ships on which device. The multi-model dashboard isn't a toy; it's the artifact you put on the screen to justify "we should ship variant C on the mid-tier SOC, here's the accuracy/latency/power frontier, here's the knee, here's the regression history." The engineer who can build the system AND defend the decision with clear data is the one who influences technical direction "without direct people-management" — which is the literal job description. Practice telling the story, not just shipping the code.
The thing nobody tells you about reaching this level: your value shifts from what you build to what you decide and what you prevent. The regression CI you build prevents a class of accuracy disasters for the whole org, forever. The NPU-coverage discipline you instill saves teams from shipping secretly-CPU-bound models. The Pareto framing you bring to reviews makes everyone's model selection better. At Senior Staff, leverage comes from raising the bar and setting the standards, not just from your own commits. The capstone systems are templates for that leverage — build them as things others can use, because that's the multiplier.
A word on the labs that skip on this machine (the C++ op, the TF tracing, the TVM compile, the AI-Hub deploy): don't read those skips as gaps in your knowledge. They skip because the proprietary or heavyweight toolchain isn't installed — not because the concept is missing. Read every line, understand the workflow, and know that when you sit down at a real Qualcomm workstation with the SDK and a device, you already know the moves. The concepts are yours; the tooling is just the steering wheel, and you've studied the road.
Career truth, brother, to close: this is one of the most defensible specializations in all of ML engineering, precisely because it demands the rare full-stack-plus-hardware combination almost nobody has. Most ML engineers stop at the framework. Most systems engineers don't know the ML. You now span PyTorch internals to NPU silicon, research technique to shipped product, raw speed to proven accuracy. That intersection is small, in brutal demand, and — as edge GenAI explodes — only getting more valuable. You didn't just prepare for an interview; you became the engineer the interview is looking for.
Build the capstones. Make the seams hold. Present the tradeoff like you mean it. Then go take the role — you've earned the bar.
— your brother 👨🏻
Capstone 01 — Regression CI Pipeline
Phase: 11 — Capstone | Difficulty: ⭐⭐⭐⭐⭐ | Time: 5–6 hours
Build a production-grade CI/CD accuracy regression gate that blocks deployments on statistically significant regressions.
What you build
BaselineManager— stores and retrieves per-task model baselinesmcnemar_test— χ² with continuity correction for paired accuracy comparisonevaluate_regression— per-task delta + McNemar p-valueCIReport— JSON report with pass/fail per task and overall gate decisionbuild_ci_report/serialize_report— pipeline entry points;sys.exit(0/1)for CI
Integrates
Phase 09 McNemar test + Phase 09 regression detection + Phase 07 profiling report format
Run
pip install -r requirements.txt
python lab.py # generates report.json
pytest test_lab.py -v
Capstone 02 — NPU Graph Optimization Pipeline
Phase: 11 — Capstone | Difficulty: ⭐⭐⭐⭐⭐ | Time: 5–6 hours
End-to-end pipeline: analyze FX graph for NPU compatibility → export ONNX → quantize → validate accuracy delta.
What you build
SUPPORTED_OPSset — ops supported by the simulated NPUanalyze_fx_graph— symbolic trace, compute op coverage %export_to_onnx— export with opset 17 to BytesIO buffercompute_accuracy_delta— relative error + cosine similarity +passes_gate(max < 5%)run_pipeline— 4-stage pipeline report with recommendations
Integrates
Phase 04 FX graph + Phase 05 ONNX + Phase 03 quantization + Phase 07 profiling recommendations
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Capstone 03 — Custom torch.compile NPU Backend
Phase: 11 — Capstone | Difficulty: ⭐⭐⭐⭐⭐ | Time: 5–6 hours
Build a
torch.compilebackend that routes eligible ops to a simulated NPU and falls back to CPU for unsupported ops.
What you build
_NPU_OPSregistry +@register_npu_opdecoratornpu_linear,npu_relu,npu_gelu— simulated NPU implementationsclassify_nodes— partition FX graph into NPU-eligible vs CPU-fallbacktransform_graph— replace ATen ops with NPU ops via FX node substitutionnpu_backend—torch.compile(model, backend=npu_backend)callableverify_backend_correctness— max absolute diff < 1e-4
Integrates
Phase 04 FX graph passes + Phase 04 custom backend + Phase 06 NPU op support concepts
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Capstone 04 — Production Quantization Suite
Phase: 11 — Capstone | Difficulty: ⭐⭐⭐⭐⭐ | Time: 5–6 hours
A complete quantization decision engine: PTQ → sensitivity analysis → mixed-precision assignment → QAT fine-tuning → report.
What you build
ptq_quantize_linear— symmetric per-tensor INT4/INT8 PTQapply_ptq— quantize all Linear layers in modelLayerSensitivity+measure_layer_sensitivity— per-layer isolation (freeze all others)PrecisionAssignment+assign_mixed_precision— drop>2%→FP16, 0.5-2%→INT8, <0.5%→INT4apply_mixed_precision— assign different bit widths per layerFakeQuantize(STE) +run_qat— fine-tune with fake quantization noiseQuantizationSuiteReport+run_quantization_suite— orchestrates all stages
Integrates
Phase 03 PTQ + Phase 03 QAT + Phase 06 sensitivity analysis + Phase 09 evaluation
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Capstone 05 — Multi-Model Dashboard
Phase: 11 — Capstone | Difficulty: ⭐⭐⭐⭐⭐ | Time: 5–6 hours
The final capstone: benchmark multiple model variants across multiple tasks, run Pareto analysis, detect regressions, and emit a structured JSON dashboard report.
What you build
ModelVariant/BenchmarkTask/TaskResult— data structuresrun_benchmark_matrix— all variant×task combinationsaggregate_results— per-model averages, sorted by accuracydominates/compute_pareto_front/find_knee— Pareto analysisdetect_regression— compare to baseline with delta thresholdDashboardReport+build_dashboard— JSON with Pareto front, knee, regression flags,recommended_variant
Integrates
Phase 09 Pareto + Phase 09 regression + Phase 07 profiling + Phase 11 CI pipeline
Sample Output
{
"models": [...],
"pareto_front": ["model_A", "model_C"],
"knee_point": "model_A",
"regressions": [],
"recommended_variant": "model_A"
}
Run
pip install -r requirements.txt
python lab.py
pytest test_lab.py -v
Interview Preparation — Model Accuracy & AI Performance Senior Staff Engineer
Overview
This directory contains targeted interview preparation for the Qualcomm Senior Staff ML Engineer — Model Accuracy & AI Performance role. The material is structured around the actual question types you will encounter.
File Structure
| File | Content | Time |
|---|---|---|
01-concepts-cheatsheet.md | Top 40 Q&A — quantization, profiling, NPU, torch.compile, eval | 4 hours read |
02-framework-internals-coding.py | Live coding: autograd, dispatch, FX passes from scratch | 6 hours practice |
03-optimization-coding.py | Live coding: FlashAttention tiling, fused kernels, roofline | 6 hours practice |
04-systems-design-questions.md | 10 system design walkthroughs with full answers | 4 hours read |
05-research-engineering-questions.md | Paper-to-code challenges (FlashAttention, GPTQ, LoRA) | 8 hours practice |
06-behavioral-star-stories.md | STAR stories for each competency area | 2 hours prep |
4-Week Interview Sprint
Week 1: Framework Fundamentals
- Read
01-concepts-cheatsheet.md(all quantization, dispatch, FX sections) - Implement all exercises in
02-framework-internals-coding.py - Review Phase 01 HITCHHIKERS-GUIDE (autograd, dispatch stack)
- Goal: can explain PyTorch dispatch stack, write a custom autograd Function, and implement a basic FX pass on a whiteboard
Week 2: Hardware and Optimization
- Read
01-concepts-cheatsheet.md(NPU, profiling, roofline sections) - Implement exercises in
03-optimization-coding.py - Review Phase 06 and 07 HITCHHIKERS-GUIDEs
- Goal: can describe Snapdragon HTP architecture, work through a roofline analysis, identify GPU performance bugs
Week 3: Systems Design
- Work through all 10 scenarios in
04-systems-design-questions.md - Read
05-research-engineering-questions.md - Implement at least 2 paper-to-code exercises
- Goal: can confidently whiteboard an accuracy regression CI system and an NPU deployment pipeline
Week 4: Polish and Behavioral
- Read and internalize
06-behavioral-star-stories.md - Mock interview (use an LLM as interviewer with the questions from files 01, 04)
- Review capstone projects and prepare to discuss each as portfolio items
- Goal: 45-minute mock interview without notes, covering all technical areas
The Senior Staff Signal
Interviewers at the Senior Staff level are evaluating:
- Depth: do you know why, not just what?
- Breadth: can you connect quantization to hardware architecture to accuracy metrics?
- Leadership: have you influenced frameworks, teams, or technical direction?
- Execution: have you shipped things at scale?
Every answer should end with: "...and here is how I know this from my experience."
Concepts Cheatsheet — Top 40 Q&A
Use this for rapid review. Answers are at the Senior Staff level — go deeper than the surface definition.
Section 1: Quantization (12 questions)
Q1. What is the quantization formula for INT8?
For affine (asymmetric) quantization: $$x_q = \text{clip}\left(\text{round}\left(\frac{x}{s}\right) + z,\ 0,\ 255\right)$$
where scale $s = (x_{max} - x_{min}) / 255$ and zero-point $z = -\text{round}(x_{min}/s)$.
Dequantization: $x \approx s \cdot (x_q - z)$
For symmetric quantization: $z = 0$, scale = $\max(|x|) / 127$.
Q2. What is the difference between per-tensor and per-channel quantization?
Per-tensor: one (scale, zero_point) for the entire tensor. Simple, low overhead.
Per-channel: one (scale, zero_point) per output channel. 4–8× better accuracy for weights because each output filter has its own dynamic range. Standard for weight quantization in deployment. Activations typically use per-tensor.
Q3. What is SQNR and what values are acceptable?
Signal-to-Quantization-Noise Ratio: $\text{SQNR (dB)} = 10 \log_{10}\frac{\mathbb{E}[x^2]}{\mathbb{E}[(x - \hat{x})^2]}$
For INT8: SQNR ≈ 48–52 dB (acceptable for most tasks). For INT4: SQNR ≈ 24–28 dB (may cause accuracy degradation on sensitive tasks). Below 20 dB: typically unacceptable for production.
Q4. What is the Straight-Through Estimator and why is it used in QAT?
In QAT, the quantize-dequantize operation is inserted in the forward pass: $\hat{x} = \text{round}(x/s) \cdot s$
The round() function has zero gradient almost everywhere (gradient is 0 except at discontinuities). Without STE, gradients don't flow backward through the quantizer.
STE approximation: $\frac{\partial \hat{x}}{\partial x} \approx 1$ (treat the quantizer as identity in the backward pass). This allows gradients to flow to weights during QAT training.
Q5. Explain GPTQ in 3 sentences.
GPTQ quantizes each row of each weight matrix sequentially using second-order information. For each weight column $j$, it quantizes $w_j$, computes the quantization error, and propagates that error to the remaining columns using the inverse Hessian of the layer's activation covariance. This is based on the OBQ algorithm but uses lazy batch updates for efficiency.
Q6. What is AWQ's key insight?
Not all weights matter equally. Weights associated with large-magnitude activations (salient channels) cause disproportionately large quantization error. AWQ scales up salient weight channels before quantization (making them "easier" to quantize), then scales down the corresponding activations by the same factor. The product $W \cdot X$ is preserved exactly.
Q7. Explain SmoothQuant.
SmoothQuant migrates quantization difficulty from activations to weights. In transformers, outliers in activations (up to 600× larger than mean) make per-tensor INT8 quantization of activations lossy. SmoothQuant: multiply activations by $\text{diag}(s)^{-1}$ and weights by $\text{diag}(s)$ where $s_j = \max|X_j|^\alpha / \max|W_j|^{1-\alpha}$. Activations become easier to quantize; weights are slightly harder but manageable.
Q8. When would you choose QAT over PTQ?
QAT is warranted when: PTQ accuracy is unacceptable (typically when target bit-width ≤ INT4, or task is sensitive), enough labeled training data is available (typically 10K–100K examples), compute budget allows fine-tuning (hours to days on GPU). PTQ is preferred for large models (7B+) where fine-tuning is expensive. AWQ/GPTQ are the middle ground: PTQ with smarter algorithms that close much of the QAT accuracy gap.
Q9. What is the "quantization cliff" in LLMs?
LLMs have a phase transition in accuracy degradation: from FP16 → INT8 is nearly lossless (< 0.5% MMLU), from INT8 → INT4 causes a moderate drop (1–3%), but from INT4 → INT3 can cause catastrophic degradation (10–30%). This is because at INT3 and below, the quantization noise exceeds the information content of the activations in some layers. Outlier channels in LLMs (large-magnitude activations) are particularly vulnerable.
Q10. What is double quantization in QLoRA?
Double quantization quantizes the quantization constants (scale factors). Standard INT4 quantization stores one FP32 scale per group of 64 weights = 0.5 bits/weight overhead. Double quantization further quantizes those scales from FP32 to INT8: reduces the scale overhead from 0.5 to ~0.125 bits/weight — about 0.37 bits/weight total savings for 7B parameter models.
Q11. How do you choose the calibration dataset for PTQ?
The calibration set should match the deployment distribution as closely as possible. For language models: WikiText-2 or C4 for general language PTQ; task-specific text for domain PTQ. Size: 128–512 samples is typically sufficient (diminishing returns beyond that). Calibration data affects activation statistics → scale computation → quantization accuracy. Mismatch between calibration and deployment distributions is a common source of unexpected accuracy degradation.
Q12. What is the quantization error accumulation problem in deep networks?
In a deep network, quantization error in early layers gets amplified by subsequent layers. A 1-bit quantization error in the embedding layer affects all subsequent attention computations. Mitigation strategies: (1) use higher precision for first/last layers, (2) apply SmoothQuant for outlier-prone layers, (3) use mixed-precision quantization (sensitive layers → INT8, bulk → INT4), (4) apply error correction via GPTQ's Hessian-based column-by-column quantization.
Section 2: PyTorch Framework Internals (8 questions)
Q13. Describe the PyTorch dispatch stack.
A tensor operation (e.g., x + y) goes through:
- Python:
__add__callstorch.add - C++ dispatcher: resolves via dispatch key priority list (Functionalize → Autograd → CUDA/CPU)
- Autograd: creates
AddBackward0node in the autograd graph (if requires_grad) - CompositeImplicitAutograd or backend-specific: calls into cuBLAS/CUDA kernels
Key dispatch keys in priority order: Functionalize, FuncTorchDynamicLayerFrontMode, InplaceOrView, Autograd (per-backend), CUDA, CPU.
Q14. What is TorchDynamo and how does it capture graphs?
TorchDynamo intercepts CPython bytecode evaluation using torch._dynamo.eval_frame.set_eval_frame(). It installs a frame evaluation callback that observes each operation, builds an FX graph, and breaks the graph when it encounters Python control flow that depends on tensor values. It generates "guards" (Python predicates) that must be true for the compiled graph to be valid — if guards fail, Dynamo recompiles.
Q15. What is a graph break in torch.compile and how do you debug it?
A graph break is a point where TorchDynamo cannot continue tracing — it stops, returns to Python, and starts a new compiled subgraph after the break point. Common causes: tensor.item(), tensor.numpy(), if tensor > 0 (data-dependent control flow), non-PyTorch library calls, unsupported Python constructs.
Debug: torch._dynamo.explain(model)(x) prints each graph break reason. torch._dynamo.config.verbose = True prints breaks during compilation.
Q16. What is AOTAutograd?
Ahead-of-Time Autograd: lifts the backward pass computation (autograd graph) into a static graph before execution. Instead of building the backward graph at runtime, AOTAutograd traces through both the forward and backward pass during compilation, producing a "joint" forward+backward graph that can be optimized end-to-end by the backend. This enables the backend to fuse forward ops with their backward kernels.
Q17. What is the difference between torch.fx.symbolic_trace and torch.export?
symbolic_trace: Python-level tracer; follows Python code; may not handle dynamic control flow or data-dependent shapes; suitable for static model analysis.
torch.export: TorchDynamo-based; handles dynamic shapes via symbolic shape expressions; produces a strict aten-level graph with no Python dependencies; intended for mobile/server deployment; verified correct by construction.
Q18. Describe the FX node types.
placeholder: function input (graph edge from outside)get_attr: access module parameter/buffercall_function: free function call (torch.relu(x))call_method: tensor method call (x.reshape(...))call_module:nn.Moduleforward call (self.linear(x))output: function return value
Q19. What is torch.compile's performance overhead?
First call: compilation overhead (Dynamo tracing + backend compilation) — 5–30 seconds depending on model size. Subsequent calls (cache hit): near-zero overhead (guard check is O(1)). Cold start is the main cost. In production: precompile before serving, or use torch.compile(mode='reduce-overhead') which uses CUDA graphs to eliminate kernel launch overhead.
Q20. How do custom ops interact with torch.compile?
Custom ops registered with TORCH_LIBRARY / torch.library participate in the dispatch system and are visible to the FX tracer as opaque nodes. To make them compilable: (1) register a fake/meta kernel that computes output shapes without CUDA, (2) register an autograd kernel for gradient support. Without a meta kernel, torch.compile cannot trace through the op (graph break or error).
Section 3: Hardware and Profiling (8 questions)
Q21. Explain the roofline model.
Performance (FLOPS/s) = min(Peak FLOPS, Peak BW × Arithmetic Intensity).
Arithmetic Intensity = FLOPS / bytes transferred from memory.
Crossover point = Peak FLOPS / Peak BW. On A100: 312 TFLOPS / 2 TB/s = 156 FLOPS/byte.
Ops with AI < 156 on A100 are memory-bound — benefit from reducing memory traffic (fusion, tiling). Ops with AI > 156 are compute-bound — benefit from better compute utilization (tensor cores, mixed precision).
Q22. What is the arithmetic intensity of decode-time attention?
At batch_size=1, seq_len=n: attention reads KV cache (O(nd) bytes), computes O(nd) FLOPS. AI ≈ 4nd / 8nd = 0.5 FLOPS/byte — deeply memory-bound regardless of hardware. This is why LLM decode performance scales with DRAM bandwidth, not TOPS.
Q23. When do you use nsys vs ncu?
nsys (Nsight Systems): system timeline — CPU/GPU timeline, kernel launches, data transfers, synchronization gaps. Use when you need to understand where time is spent at a high level — is GPU busy, are there transfers, is there CPU overhead?
ncu (Nsight Compute): per-kernel deep analysis — FLOP utilization, memory bandwidth, warp stalls, occupancy. Use when you've identified which kernel is slow and want to know why.
Q24. What is CUDA occupancy and when does it matter?
Occupancy = active warps / max warps per SM. Limited by: register pressure, shared memory usage, block size.
High occupancy matters when the kernel is latency-bound (waiting for memory): more warps → scheduler hides latency by switching. Low occupancy is fine if the kernel is compute-bound (100% FLOP utilization).
Low occupancy from excessive register use is a common issue with complex kernels — use launch_bounds to limit registers and increase occupancy.
Q25. What is the HTP on Qualcomm Snapdragon?
Hexagon Tensor Processor: dedicated ML accelerator. Components: HMX (Hexagon Matrix Extension) for matrix multiplications, HVX (Hexagon Vector Extension) for elementwise/activation ops, 8 MB VTCM on-chip SRAM (2 TB/s bandwidth). Peak: ~73 TOPS INT8 on 8 Gen 3. Ops that fall back to CPU cause expensive data transfers.
Q26. Why do some ops fall back to CPU on the HTP?
- Dynamic output shapes (e.g.,
nonzero(),unique()) - Op not in QNN's HTP library for the given data type (e.g., FP32 ops are CPU-only; use INT8 or FP16)
- Data type mismatch between compile-time specs and runtime values
- Graph topology that breaks HTP's requirements (e.g., non-supported data flows)
CPU fallback causes a data copy between CPU and HTP memory — often 100× more expensive than the op itself.
Q27. How do you benchmark a PyTorch function reliably?
Use torch.utils.benchmark.Timer:
- It calls
torch.cuda.synchronize()before and after to account for async GPU execution - It runs warm-up iterations first (eliminates JIT compilation and caching artifacts)
- It reports mean, median, and IQR over multiple runs
- It disables garbage collection during timing
Never use time.time() directly for CUDA timing — it does not synchronize the GPU.
Q28. What is memory coalescing?
When 32 threads in a CUDA warp access 32 consecutive memory addresses (aligned to 128 bytes), the GPU combines them into one DRAM transaction. Non-coalesced access (e.g., strided access where each thread's address is not adjacent) requires one transaction per thread — 32× more DRAM bandwidth.
Fix: ensure matrix access patterns are row-major for row-parallel kernels, or use shared memory tiling to convert non-coalesced → coalesced access patterns.
Section 4: Model Accuracy and Evaluation (6 questions)
Q29. How do you compute perplexity correctly for long documents?
Use the stride trick: process the document in overlapping windows so each token has the maximum context from preceding tokens. The crucial detail: only count the loss for tokens that were NOT in the previous window (they had full context). See Phase 09 HITCHHIKERS-GUIDE for the stride implementation.
Q30. Why is McNemar's test preferred over a t-test for accuracy comparison?
McNemar is for paired binary outcomes — each test example is either correct (1) or incorrect (0). The t-test assumes continuous, independently sampled values. Two models tested on the same examples are not independent — their errors are correlated. McNemar's test exploits this correlation (only examples where models disagree are informative).
Q31. You observe MMLU dropped 0.8% after INT4 quantization. Significant?
Compute McNemar's test: $\chi^2 = (|n_{01} - n_{10}| - 1)^2 / (n_{01} + n_{10})$. With 14k examples and 0.8% drop ≈ 112 net changes, if the standard error is 0.37% (typical), then 0.8% is ~2.2 SE. Apply McNemar to get exact p-value. With evenly split changes (56+56 disagreements): $\chi^2 = (0-1)^2/112 ≈ 0.01$, p ≈ 0.92 — not significant. Need to see the actual per-example results.
Q32. What is calibration in the context of model accuracy?
A calibrated model's stated confidence = empirical accuracy in that confidence bucket. Measured by ECE (Expected Calibration Error). Quantization often worsens calibration — the model becomes more overconfident or underconfident even if overall accuracy is maintained. Check calibration before shipping quantized models, especially in high-stakes domains.
Q33. What benchmarks would you run to characterize a new quantization method?
Perplexity on WikiText-2 (sensitivity to language modeling), MMLU 5-shot (knowledge/reasoning), HellaSwag (common sense), ARC-Challenge (hard reasoning), GSM8K (math). Also: calibration (ECE), throughput and latency benchmark, memory footprint. Run at multiple bit-widths to map the accuracy-efficiency frontier.
Q34. How do you detect accuracy regression caused by a new optimization?
Set up: (1) eval harness that produces per-example results (not just aggregate), (2) baseline stored for the current main branch, (3) McNemar's test comparing per-example results, (4) policy: fail if p < 0.05 AND absolute drop > threshold (0.5%). The threshold prevents noise-level changes from failing CI while still catching real regressions.
Section 5: GenAI Systems and Edge (6 questions)
Q35. How would you run a 7B LLM on a device with 4 GB DRAM?
- INT4 quantization: 7B × 0.5 bytes = 3.5 GB (just fits)
- Use NF4 or AWQ for best accuracy at INT4
- Sliding window attention to limit KV cache growth
- Chunked prefill to bound peak memory
- If 3.5 GB still too much: use INT2 (AQLM) for ~1.75 GB, with LoRA accuracy recovery
Q36. What is speculative decoding and prove it is lossless.
Speculative decoding: draft model proposes k tokens; target model verifies all k+1 positions in one forward pass; accept tokens where draft distribution ≤ target distribution; reject and resample from corrected distribution otherwise.
Losslessness proof: acceptance probability = min(1, p(x)/q(x)), rejection probability = 1 - ΣP(accept). Corrected distribution = norm(max(0, p(x)-q(x))). The marginal output distribution = min(p,q) + correction = p(x). ∎
Q37. What is the difference between SNPE and QNN?
SNPE (Snapdragon Neural Processing Engine) is the legacy SDK (2nd generation). QNN (Qualcomm Neural Network SDK) is the current SDK (3rd generation) with per-channel INT8/INT16/FP16, better op coverage, wider device support (Snapdragon 8 Gen 1+), and a cleaner API. Use QNN for all new projects; SNPE only for legacy embedded targets.
Q38. Explain LoRA. Why does it work?
LoRA adds trainable low-rank matrices $W = W_0 + BA$ where $B \in \mathbb{R}^{d \times r}$, $A \in \mathbb{R}^{r \times k}$, $r \ll \min(d,k)$. It works because pre-trained model weight updates have low intrinsic rank — the fine-tuning signal lives in a much lower-dimensional subspace than the full weight matrix. A rank-64 LoRA captures most of the useful adaptation with only $64(d+k)$ parameters vs $dk$ for full fine-tuning.
Q39. What is paged attention and why does it matter?
Paged attention stores the KV cache in non-contiguous memory pages (e.g., 16 tokens/page). A page table maps logical sequence positions to physical pages. Benefits: (1) no memory waste from pre-allocating max-sequence-length buffers, (2) multiple requests can share common prefix pages (prefix caching), (3) easy eviction/swap for memory-pressure situations. Enables 2–3× higher GPU utilization in serving systems vs naive contiguous KV cache.
Q40. Design the accuracy evaluation cadence for a new chip tapeout.
- Pre-tapeout (simulation): run full accuracy suite on ISA simulator; compare to GPU golden reference; any regression blocks tapeout
- Post-tapeout bringup: run on 3–5 representative models; verify INT8 accuracy within 0.5% of FP16 baseline; identify HTP fallback ops
- Production release: run full benchmark suite (20+ models, 3 bit-widths each); publish accuracy comparison table; run McNemar tests for all significant deltas
- Ongoing CI: every SDK release triggers automated accuracy regression check; any model that regresses >1% accuracy flags a review
Interview Prep — File 04: Systems Design Questions
=====================================================
10 realistic system design walkthroughs for senior staff ML engineer interviews.
Each question includes: problem statement, clarifying questions, architecture,
key decisions, back-of-envelope calculations, failure modes, and talking points.
Design Prompts Overview
| # | Question | Core Concepts |
|---|---|---|
| 1 | Accuracy regression CI pipeline | Eval harness, stats, CI/CD |
| 2 | NPU deployment pipeline for Snapdragon | SNPE/QNN, calibration, validation |
| 3 | Quantization error recovery system | QAT, ADAROUND, sensitivity |
| 4 | Real-time model serving at 100k QPS | batching, autoscaling, SLO |
| 5 | Distributed offline evaluation platform | Ray/Spark, scheduling, dedup |
| 6 | Model performance benchmarking infra | Standardization, reproducibility |
| 7 | Online accuracy monitoring for deployed models | drift detection, shadow eval |
| 8 | GenAI on-device inference pipeline | memory budget, INT4, streaming |
| 9 | A/B testing framework for model variants | traffic splitting, significance |
| 10 | Continual training + accuracy preservation | EWC, replay, forgetting metrics |
Design 1: Accuracy Regression CI Pipeline
Prompt: Design a CI system that detects accuracy regressions when engineers submit PRs with model changes.
Clarifying Questions
- What benchmarks are required? (MMLU, HellaSwag, task-specific)
- What is the acceptable CI time? (<10 min vs <2 hr)
- Is 0.1% accuracy drop OK? Or 0% regression tolerance?
- How many model checkpoints per PR? (1 vs N experiments)
- Infrastructure: on-prem GPU cluster or cloud?
Architecture
PR Webhook
│
▼
[Trigger Service] → picks eval tier based on PR diff size
│
├── Tier 1 (fast, <5 min): N=100 MMLU subset, perplexity probe on 1k tokens
│
└── Tier 2 (full, <2 hr): full MMLU, HellaSwag, MT-Bench, task-specific evals
│
▼
[Eval Workers] (GPU fleet, Ray cluster)
│ - Download model checkpoint from artifact store
│ - Run harness in parallel across benchmarks
│ - Write results → central DB
│
▼
[Stats Engine]
│ - Fetch baseline results (main branch) from DB
│ - Compute Δacc, McNemar's test p-value for each benchmark
│ - Apply regression policy: flag if any Δacc < -0.5% with p < 0.05
│
▼
[PR Comment Bot] → posts table of before/after accuracy per benchmark
│
└── Pass/Fail gate → blocks merge if regression detected
Key Design Decisions
-
Baseline pinning: Store baseline results per model variant in a versioned database (PostgreSQL + TimescaleDB). Never recompute baselines at PR time — they drift.
-
Statistically valid comparisons: Use McNemar's test (paired accuracy), not just point estimate difference. Threshold: p < 0.05 AND |Δacc| > 0.5%.
-
Fast path vs full path: Tier 1 runs on every PR (5 min). Tier 2 runs only when model weights change (detected via md5 of checkpoint), not just code changes.
-
Parallelism: Each benchmark runs on a separate GPU. For 10 benchmarks at 2 min each = 2 min wall time with 10 GPUs.
-
Caching: Cache forward pass outputs (logits) for unchanged model files. Eval reuses cached logits to recompute metrics without re-running the model.
Back-of-Envelope
- MMLU (57 subjects × 100 examples × 4 choices) = 22,800 forward passes
- GPT-2 @ 10ms/pass on A10G = 228 seconds
- LLaMA-7B @ 100ms/pass = 2,280 seconds → need batching or sampling
- With batch_size=32: 22,800/32 = 712 batches × 30ms = ~22 seconds
Failure Modes & Mitigations
- GPU OOM on eval worker: Cap batch size; use gradient checkpointing flag; shard model
- Flaky results (stochastic decoding): Use greedy decoding for classification; set seeds
- Baseline drift: Recompute baselines on a weekly cadence; alert when baseline changes
- False positives: Two-sided test with Bonferroni correction for multiple benchmarks
Design 2: NPU Deployment Pipeline for Snapdragon
Prompt: Build an automated pipeline that takes a PyTorch model and produces a validated .dlc (SNPE) or .bin (QNN) artifact for Snapdragon deployment.
Pipeline Stages
PyTorch Model (FP32)
│
▼ [Stage 1: Export]
torch.export() / torch.onnx.export()
→ model.onnx
│
▼ [Stage 2: Optimization]
onnxoptimizer.optimize() + onnxsim
Fusion: Conv+BN+ReLU, GELU approximation
→ model_optimized.onnx
│
▼ [Stage 3: Quantization Calibration]
SNPE quantization tool or QNN quantize_model
Calibration dataset: 500 representative samples
→ model_quantized.onnx (INT8 weights + INT8 activations)
│
▼ [Stage 4: Compilation]
snpe-onnx-to-dlc --input_dim "input_1:1,3,224,224"
OR: qnn-onnx-converter + qnn-model-lib-generator
→ model.dlc / model.bin
│
▼ [Stage 5: Validation on-device / AI Hub]
snpe-net-run --container model.dlc --input_list inputs.txt
Compare: FP32 baseline vs INT8 output (CosSim > 0.99 threshold)
Latency: measured on HTP (target < 10ms for 224×224 images)
│
▼ [Stage 6: Artifact Registry]
Store: model.dlc + metadata.json + benchmark_results.json
Trigger: mobile CI test suite
Key Decisions
-
Calibration data: Must be representative of production distribution. Use 500 images from each class for classification; 100 conversations for LLMs. Never use test set.
-
Accuracy validation metric: Cosine similarity between FP32 and INT8 outputs (layer-by-layer). Any layer with CosSim < 0.99 triggers a precision upgrade to FP16 for that layer.
-
Op coverage: SNPE/QNN don't support all PyTorch ops. Pre-check with
snpe-onnx-to-dlc --dry_run. Common unsupported ops: custom activations, unfoldable reshapes, complex attention patterns. -
Fallback strategy: CPU/GPU fallback for unsupported HTP ops. Profile: target >80% of ops on HTP.
-
AI Hub integration: Use
qai_hub.submit_compile_job()+qai_hub.submit_profile_job()for on-device measurement without physical hardware.
Accuracy / Latency Trade-off Table
| Precision | Accuracy Drop | Latency (HTP) | File Size |
|---|---|---|---|
| FP32 | 0% | 50ms | 100% |
| FP16 | < 0.1% | 25ms | 50% |
| INT8 | 0.5-1.5% | 10ms | 25% |
| INT4 (W4A8) | 1-3% | 6ms | 12.5% |
Design 3: Quantization Error Recovery System
Prompt: Build a system that automatically applies quantization-aware training (QAT) to recover accuracy lost during PTQ.
Decision Framework
PTQ Results
│
▼
Is accuracy drop < threshold? (e.g., <1% for INT8, <3% for INT4)
YES → ship PTQ model
NO → run sensitivity analysis
│
▼
Per-layer sensitivity:
Quantize one layer at a time, measure accuracy drop
Rank layers by sensitivity
│
▼
Mixed-precision:
Sensitive top-20% layers → FP16 or INT8
Rest → INT4
│
▼
If still insufficient → QAT
Initialize from pre-trained weights
Insert fake-quant nodes (torch.quantization.prepare_qat)
Fine-tune for 1-5% of original training steps (LR = 1e-4 × base_LR)
Use straight-through estimator (STE) for quantization gradient
│
▼
Final accuracy check → pass/fail
Key Algorithms
ADAROUND (Nagel et al. 2020): Instead of round-to-nearest, optimize rounding decisions per weight:
min_v ||W - Q(W, v)||_F^2 + λ * f_reg(v)
where Q(W, v) = floor(W/scale) + v (v ∈ {0,1})
f_reg(v) = regularization to push v toward 0 or 1
Recovers ~0.5-1% vs standard rounding.
SmoothQuant (Xiao et al. 2022): Transfer quantization difficulty from activations to weights:
Y = (X * diag(s)^{-1}) @ (diag(s) * W)
Choose s_j = max(|X_j|)^α / max(|W_j|)^{1-α}
Makes activations easier to quantize (less outliers).
Failure Modes
- QAT divergence: LR too high; use warm-up + cosine decay
- Calibration set overfitting: Use held-out validation set for sensitivity ranking
- INT4 fails for attention: Key/Query projections need INT8 or FP16 (high sensitivity)
Design 4: Real-time Model Serving at 100k QPS
Prompt: Design serving infrastructure for an LLM-based feature at 100k requests per second with p99 latency < 100ms.
Back-of-Envelope
- 100k QPS, avg input 50 tokens, avg output 20 tokens
- LLaMA-7B on A100: 100 tokens/s decode → 20 tokens × 10ms = 200ms → TOO SLOW for single request
- Need: batching, speculative decoding, or smaller model
Revised approach:
- 50ms budget → max 50 tokens/s per request → max 50 tokens output
- With batch_size=64 on A100: 64 × 50 tokens/s = 3200 tokens/s throughput
- A100 fleet needed: 100k req/s × (70 tokens/req) / 3200 tokens/s/GPU = 2,188 GPUs
- Alternative: distilled 1B model → 10× faster → 219 GPUs
Architecture
Load Balancer (L7, consistent hash on user_id)
│
├── Frontend pods (stateless, auth, rate limit)
│
▼
Request Router (picks inference cluster based on model version)
│
▼
Inference Cluster (per model version, continuous batching)
│
├── vLLM / TGI serving with:
│ - PagedAttention (KV cache pooling)
│ - Continuous batching (no padding waste)
│ - Speculative decoding (small draft model)
│
▼
Response Streaming (SSE / WebSocket for token-by-token output)
Latency Budget
| Component | Budget |
|---|---|
| Network (client → LB) | 5ms |
| Auth + rate limit | 2ms |
| Queue wait | 5ms |
| Prefill (50 tokens) | 20ms |
| Decode (20 tokens × 2ms) | 40ms |
| Network (LB → client) | 5ms |
| Total p50 | 77ms |
Key Optimizations
- Continuous batching (vs static batching): 30-50% throughput improvement
- KV cache paging (PagedAttention): eliminates memory fragmentation, 2× capacity
- Speculative decoding: 3-5× speedup for repetitive text at cost of draft model
- INT8 serving: 2× throughput, marginal accuracy loss with calibrated models
Design 5: Distributed Offline Evaluation Platform
Prompt: Build a platform that runs weekly accuracy evaluations of 100+ model checkpoints across 50 benchmarks.
Scale
- 100 models × 50 benchmarks × 1000 examples = 5M evaluations
- Each eval: ~100ms → 140 GPU-hours → need parallelism
Architecture
Job Scheduler (Airflow DAG, weekly trigger)
│
▼
Task Graph:
For each (model, benchmark) pair:
1. Fetch model checkpoint (S3/GCS) — dedup by sha256
2. Run eval worker (Ray remote task)
3. Write results to Results DB (PostgreSQL + partitioned by date)
4. Aggregate into dashboard
Ray Cluster:
Head node: DAG orchestration
Worker nodes: 50× A10G GPUs
GPU memory: load model once, eval all benchmarks, release
Deduplication:
- Cache eval results by (model_sha, benchmark_version)
- Skip re-evaluation if hash unchanged → typical: only 30% of checkpoints changed
Results Database Schema
CREATE TABLE eval_results (
id BIGSERIAL PRIMARY KEY,
model_id TEXT NOT NULL,
model_sha CHAR(64) NOT NULL,
benchmark TEXT NOT NULL,
n_correct INT,
n_total INT,
accuracy FLOAT,
eval_time_s FLOAT,
evaluated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON eval_results (model_id, benchmark, evaluated_at DESC);
CREATE UNIQUE INDEX ON eval_results (model_sha, benchmark); -- dedup
Monitoring
- Alert: job fails to complete in 24h
- Alert: accuracy drops > 2% vs previous week for any (model, benchmark)
- Dashboard: accuracy over time per model family
Design 6: Model Performance Benchmarking Infrastructure
(See system-design/05-model-perf-benchmarking-infra.md)
Design 7: Online Accuracy Monitoring for Deployed Models
Prompt: Monitor accuracy of a production model serving 10M requests/day without ground truth labels.
Approach: Shadow Evaluation + Proxy Metrics
Production Traffic (10M/day)
│
├─── Production Model → response to user
│
└─── Shadow Model (previous version)
│
▼
Compare outputs:
- Token-level agreement rate
- Semantic similarity (embedding cosine)
- Length distribution (statistical test)
│
▼
Sample 0.1% for human annotation (10k/day)
│
▼
Accuracy estimate with confidence interval
Drift Detection
- Concept drift: Track input distribution (embedding PCA, feature statistics)
- Data drift: Track input length, vocabulary distribution (KL divergence alert)
- Output drift: Track token entropy, refusal rate, sentiment
Alert Thresholds
- Agreement rate drops by > 5% vs 7-day rolling average → page on-call
- Human annotation accuracy drops by > 1.5% → rollback candidate
- KL divergence of input distribution > 0.5 → model retraining trigger
Design 8: GenAI On-Device Inference Pipeline
Prompt: Deploy a 7B parameter LLM on Snapdragon 8 Gen 3 (16 GB LPDDR5, 45 TOPS NPU).
Memory Budget
| Component | Size (INT4) |
|---|---|
| 7B params at W4A8 | 3.5 GB |
| KV cache (max 512 tokens, 32 layers, 2 heads, 128d) | 128 MB |
| Activations | 64 MB |
| Runtime overhead | 200 MB |
| Total | ~4 GB → fits in 16 GB |
Layer Streaming Strategy
For models >4 GB (e.g., 13B INT4 = 6.5 GB):
Flash storage (60 GB/s NVMe) → prefetch next 4 layers while computing current 4
Working set: 4 layers in HBM × 2 (double-buffer) = ~1 GB active
Effective: overlap compute and I/O
Compilation Pipeline
- GGUF format (llama.cpp) → direct quantization, no conversion needed
- QNN compilation:
qnn-context-binary-generatorfor HTP caching - First-run: compile + cache
.qnncachebinary (30s) - Subsequent runs: load from cache (200ms)
Optimization Checklist
- INT4 weights (AWQ or GPTQ)
- FP16 KV cache (INT8 possible with <0.2% accuracy loss)
- GQA with n_kv_heads=4 (8× KV cache reduction vs MHA)
- Sliding window attention (max context 2048, window 512)
- Speculative decoding with 68M draft model
Design 9: A/B Testing Framework for Model Variants
Prompt: Design an A/B testing system for safely rolling out a new model version.
Requirements
- Traffic split: 10%/90% initially → ramp to 50%/50%
- Primary metric: task-specific accuracy (requires labels or human eval)
- Guardrail metrics: latency p99, error rate, user engagement
- Statistical significance: 95% confidence, minimum 10k samples per variant
Architecture
User Request
│
▼
[Experiment Assignment] (consistent hash by user_id for sticky sessions)
├── Group A (control, current model)
└── Group B (treatment, new model)
│
▼
[Logging] → event stream (Kafka)
│
▼
[Stats Engine] (hourly batch)
│ - Compute accuracy / latency per group
│ - Run two-proportion z-test for accuracy
│ - Run Mann-Whitney U for latency
│ - Check guardrail violations
│
▼
[Dashboard + Auto-ramp]
- If p < 0.05 AND accuracy_B > accuracy_A AND no guardrail violations:
ramp traffic to 20% → 50% → 100% over 24h
- If guardrail violated: auto-rollback, page on-call
Sample Size Calculation
For accuracy test:
- Baseline accuracy: 75%
- MDE: 1% (detect 74% vs 75%)
- α = 0.05, β = 0.20 (80% power)
- n ≈ 2 × (z_α/2 + z_β)² × p(1-p) / (MDE)²
- n ≈ 2 × (1.96 + 0.84)² × 0.75 × 0.25 / 0.01² ≈ 29,400 samples per arm
Design 10: Continual Training + Accuracy Preservation
Prompt: Update a deployed model on new data without forgetting previously learned tasks (catastrophic forgetting).
Strategies
1. Elastic Weight Consolidation (EWC)
L_total = L_new_task + λ * Σ_i F_i (θ_i - θ*_i)²
F_i = Fisher information = E[∂²log P/∂θ_i²] (importance of param i)
- Cost: 2× memory (store F and θ*)
- Effect: weights important for old tasks resist change
2. Replay Buffer
- Store 1-5% of old task examples
- Mix with new task data during training
- Effective but expensive for large datasets
3. LoRA + Task-specific Adapters
- Freeze base model, train lightweight adapters per task
- No forgetting by construction
- Inference: load base + current task adapter
Accuracy Tracking During Continual Training
for each training step t:
train on new_task_batch
if t % eval_every == 0:
for task in old_tasks:
acc[task][t] = evaluate(model, task.validation_set)
backward_transfer = mean(acc[task][end] - acc[task][start] for task in old_tasks)
if backward_transfer < -0.02: # >2% forgetting
log_alert(f"Catastrophic forgetting detected: {backward_transfer:.1%}")
apply_ewc_regularization()
Metrics
- Backward Transfer (BT): Accuracy on old tasks after training on new task. Target: BT > -2%.
- Forward Transfer (FT): Accuracy on new task vs training from scratch. Target: FT > 0%.
- Plasticity-Stability Score: BT × FT normalized → composite metric.
Interview Prep — File 05: Research Engineering Questions
============================================================
Paper-to-code challenges and research discussion questions.
For each: explain the paper, implement the key algorithm, discuss trade-offs.
Section A: Paper-to-Code Challenges (45 min each)
Challenge 1: GPTQ (Frantar et al., 2022)
Paper summary: GPTQ quantizes LLM weights to INT4 using Optimal Brain Quantization (OBQ). Instead of rounding each weight independently, it minimizes the output error of each layer by solving:
$$\min_{\hat{W}} |WX - \hat{W}X|_F^2$$
It processes columns left-to-right, quantizing one column at a time and updating remaining columns to compensate for the quantization error using the inverse Hessian.
Key equations: $$\hat{w}F = \arg\min{\hat{w}_F} \frac{(w_F - \hat{w}F)^2}{[H_F^{-1}]{FF}}$$
$$W_{-F} \leftarrow W_{-F} - \frac{w_F - \hat{w}F}{[H_F^{-1}]{FF}} (H_F^{-1})_{-F,F}$$
Implement:
def gptq_quantize_layer(W, H, bits=4, blocksize=128):
"""
Quantize weight matrix W to `bits` bits using GPTQ.
H: Hessian approximation H = 2 * X @ X.T (X = layer inputs)
Returns: W_quantized (same shape), W_scale (per-group or per-channel)
"""
Q = W.clone()
Losses = torch.zeros_like(W)
# Cholesky decomposition of H for numerical stability
H = H + 1e-6 * torch.eye(H.shape[0], device=H.device) # Tikhonov regularization
H_inv = torch.linalg.inv(H)
# Process in column blocks
for i in range(0, W.shape[1], blocksize):
block_end = min(i + blocksize, W.shape[1])
W_block = Q[:, i:block_end]
H_inv_block = H_inv[i:block_end, i:block_end]
# Quantize column by column within block
for j in range(block_end - i):
col = j + i
w = Q[:, col]
d = H_inv[col, col] # diagonal element
# Quantize w
scale = w.abs().max() / (2 ** (bits - 1) - 1)
scale = scale.clamp(min=1e-8)
w_q = (w / scale).round().clamp(-(2 ** (bits-1)), 2 ** (bits-1) - 1) * scale
Q[:, col] = w_q
# Error = quantization residual / Hessian diagonal
err = (w - w_q) / d
# Update remaining columns in block using off-diagonal Hessian
Q[:, col+1:block_end] -= err.unsqueeze(1) * H_inv[col, col+1:block_end].unsqueeze(0)
return Q
Key interview discussion points:
- Why process column-by-column? Allows compensation for each quantization error before moving to next column
- Why use Hessian? Captures input activation statistics; directions with large activation need higher precision
- Computational cost: O(d² × n) for Hessian inversion; amortized by reuse across rows
- vs AWQ: GPTQ minimizes reconstruction error; AWQ minimizes activation scaling separately
Challenge 2: FlashAttention-2 (Dao, 2023)
Key improvements over FA1:
- Sequence parallelism: parallelize over Q blocks (not just K/V blocks)
- Reduced non-matmul FLOPs: fewer rescaling operations
- Causal masking: skip entire tiles above diagonal
Tile loop structure (FA2 vs FA1):
FA1: Outer loop over Q tiles, Inner loop over K/V tiles
FA2: Outer loop over K/V tiles, Inner loop over Q tiles (GPU-friendly)
→ Each warp handles full K/V tile, threads parallelize over Q
Online softmax recurrence (FA2):
# For each K/V tile, update all Q rows in parallel
m_new = max(m_old, rowmax(Q_i @ K_j.T / sqrt(d)))
O = diag(exp(m_old - m_new)) @ O + P_ij @ V_j
l = diag(exp(m_old - m_new)) @ l + rowsum(P_ij)
# Final: O = O / l
Memory complexity analysis:
- Standard attention: O(T²) for storing attention matrix
- FlashAttention: O(T) — stores running statistics only
- HBM reads/writes: Standard=4T², FA=5T (dominated by Q, K, V, O)
Interview question: "When does FlashAttention NOT improve performance?"
- For very small T (< 32): overhead of tiling logic dominates
- When attention matrix already fits in SRAM: no HBM savings
- Non-square attention (e.g., cross-attention with very different T_q and T_k): tiling is suboptimal
Challenge 3: AWQ (Lin et al., 2023)
Key insight: In LLMs, a small fraction of weight channels (<1%) are "salient" — their corresponding input activations have high magnitude. Quantizing these channels more aggressively causes large errors. AWQ finds a scale factor s per input channel that makes weights easier to quantize by reducing the effective range.
Algorithm:
- Compute per-channel activation magnitude:
a_j = mean(|x_j|)across calibration data - Find optimal scale:
s_j = a_j^α(grid search α ∈ [0, 1]) - Transform:
W' = W * diag(s),x' = x * diag(s)^{-1}(absorbed into previous LayerNorm) - Quantize W' (now with more uniform magnitude)
Why it works:
- Salient channels: large
a_j→ larges_j→ smalla_j * s_j^{-1}→ less quantization error - Non-salient channels: small
a_j→ smalls_j→ no change - Net: shifts the "hardness" from salient channels to non-salient channels
Code sketch:
def awq_search_scale(W, x, bits=4, grid_size=20):
"""Find optimal per-channel scale via grid search."""
a_j = x.abs().mean(dim=0) # [in_features]
best_loss = float('inf')
best_s = torch.ones_like(a_j)
for alpha in torch.linspace(0, 1, grid_size):
s = a_j.pow(alpha)
W_scaled = W * s.unsqueeze(0) # [out, in] * [in]
W_q = quantize(W_scaled, bits) # fake quant
# Reconstruction loss: ||W_scaled - W_q||² weighted by activation
loss = ((W_scaled - W_q) * a_j.unsqueeze(0)).pow(2).mean()
if loss < best_loss:
best_loss = loss
best_s = s
return best_s
Section B: Research Discussion Questions
B1: "Walk me through how torch.compile works end-to-end"
Expected answer structure:
-
TorchDynamo (Python bytecode → FX graph):
- Intercepts Python bytecode at the
RESUMEinstruction - Converts Python operations into an FX graph via
OutputGraph - Handles Python control flow by "breaking" the graph at dynamic branches (graph breaks)
- Caches compiled graphs; re-compiles on guard failure
- Intercepts Python bytecode at the
-
AOTAutograd (forward + backward tracing):
- Traces both forward and backward passes via
make_fxandFunctionalizationInterpreter - Produces a single joint graph of forward + backward (for memory optimization)
- Applies operator fusion and dead code elimination
- Traces both forward and backward passes via
-
Backend dispatch (FX graph → executable):
- Default: Inductor → generates Triton kernels for GPU, C++ for CPU
- Alternative: eager (no optimization), custom backends
- Inductor: applies pointwise fusion, persistent reduction, matrix tile scheduling
-
Guard system (correctness):
- Guards check: input shapes, dtypes, Python object identities
- If guard fails: fall back to uncompiled execution
assume_static_by_default=True→ recompile on new shapes unlessdynamic=True
Common interview follow-up: "What causes graph breaks and how do you fix them?"
- Dynamic Python code (loops with variable bounds, data-dependent control flow)
- Unsupported Python builtins (print, some list ops)
- Fix: use
torch.jit.is_scripting(), rewrite with tensor ops, usetorchdynamo.explain(fn, *args)to find breaks
B2: "Explain quantization-aware training (QAT) vs post-training quantization (PTQ)"
| PTQ | QAT | |
|---|---|---|
| Calibration data | 100-1000 samples | Full training set |
| Training required | No | Yes (fine-tuning) |
| Training time | Minutes | Hours-days |
| Accuracy (INT8) | -0.5 to -1.5% | -0.1 to -0.5% |
| Accuracy (INT4) | -3 to -8% | -1 to -3% |
| Use case | Deployment deadlines, no GPU time | Production models with accuracy budget |
STE (Straight-Through Estimator): The key trick in QAT. Quantization is non-differentiable (step function). STE approximates gradient as 1 in [-clip, clip]:
class FakeQuant(torch.autograd.Function):
@staticmethod
def forward(ctx, x, scale, zero_point, qmin, qmax):
x_q = torch.clamp(torch.round(x / scale) + zero_point, qmin, qmax)
x_dq = (x_q - zero_point) * scale
ctx.save_for_backward(x, scale)
return x_dq
@staticmethod
def backward(ctx, grad_output):
x, scale = ctx.saved_tensors
# STE: pass gradient through (as if no quantization happened)
return grad_output, None, None, None, None
B3: "How would you debug a 20% accuracy regression after quantization?"
Systematic debugging protocol:
-
Layer-by-layer sensitivity analysis:
for i, layer in enumerate(model.layers): model_copy = deep_copy(model) quantize_only_layer(model_copy, i) acc = evaluate(model_copy, val_set) sensitivity[i] = baseline_acc - accSort by sensitivity. If top-1 sensitive layer has 15% drop → that layer is the cause.
-
Activation range inspection:
- Look for layers with extremely wide activation ranges (outliers)
- Symptom: calibration scale is huge because a few outliers dominate
- Fix: percentile calibration, SmoothQuant, or FP16 for that layer
-
Weight distribution inspection:
- Plot weight histogram per layer: bimodal or heavy-tailed → hard to quantize
- Fix: channel-wise quantization, GPTQ, AWQ
-
Cosine similarity between FP32 and INT8 outputs:
for name, (fp32_out, int8_out) in layer_outputs.items(): cos_sim = F.cosine_similarity(fp32_out.flatten(), int8_out.flatten(), dim=0) if cos_sim < 0.99: print(f"Layer {name}: CosSim={cos_sim:.4f} ← problem layer") -
Check attention layers specifically:
- Softmax input has large variance → exp() amplifies quantization noise
- Fix: FP16 attention with INT8 projection layers only
B4: "Explain the Pareto frontier in model accuracy vs. latency"
Definition: A set of models M* where no model is strictly better than another on ALL objectives:
- M_i dominates M_j if: acc(M_i) ≥ acc(M_j) AND latency(M_i) ≤ latency(M_j)
- Pareto frontier = set of non-dominated models
Practical use at Qualcomm:
- Quantize at INT8, INT4, FP16 → 3 points on frontier
- Prune to 50%, 75%, 90% sparsity → more points
- Distill to 0.5B, 1B, 3B, 7B → cross the efficiency boundary
Interview answer: "When picking a model for production, I'd first define the hard constraint (e.g., latency < 10ms on HTP), then choose the most accurate model satisfying that constraint from the Pareto frontier — not just the fastest or most accurate in isolation."
B5: "What is the memory bandwidth bottleneck in LLM decode, and how do you solve it?"
Analysis:
During decode (batch_size=1, generating one token at a time):
- Every token requires reading ALL model weights (7B params × 2 bytes = 14 GB)
- A100 HBM bandwidth: 2 TB/s → 14 GB / 2 TB/s = 7ms per token
- A100 compute: 312 TFLOP/s; for 7B params, matmul FLOPs = 2×7B×1×1 = 14 GFLOP
- Compute time: 14G / 312T = 0.045ms → 155× faster than memory → fully memory-bound
Solutions:
-
Larger batch size: amortizes weight reads over more requests
- batch=1: 7ms/token; batch=16: 7ms/16 ≈ 0.44ms/token (same weight read, 16× more tokens)
-
Weight quantization (INT4): 7B × 0.5 bytes = 3.5 GB → 7ms/2 = 3.5ms/token
-
Speculative decoding: draft model generates 4 tokens speculatively; target model verifies all 4 in one forward pass → effective throughput 3-5× higher
-
Multi-query attention (GQA): KV cache reduction: 32-head → 4-head KV = 8× less KV reads
-
Continuous batching: always keep GPU busy; never wait for slow requests
Section C: Implementation Drills (15 min each)
Drill 1: Compute MMLU accuracy from raw logits
def score_mmlu_from_logits(logits, choices_token_ids, correct_idx):
"""
logits: [vocab_size] — model output for the position before the answer
choices_token_ids: [4] — token IDs for " A", " B", " C", " D"
Returns: True if predicted choice matches correct_idx
"""
choice_logits = logits[choices_token_ids]
predicted = choice_logits.argmax().item()
return predicted == correct_idx
Drill 2: Compute perplexity
def compute_perplexity(model, tokenizer, text, stride=512, max_length=2048):
"""
Compute perplexity with sliding window for long texts.
"""
encodings = tokenizer(text, return_tensors="pt")
seq_len = encodings.input_ids.shape[1]
total_nll, total_tokens = 0.0, 0
prev_end = 0
for begin in range(0, seq_len, stride):
end = min(begin + max_length, seq_len)
tgt_len = end - prev_end
input_ids = encodings.input_ids[:, begin:end]
with torch.no_grad():
outputs = model(input_ids, labels=input_ids)
# outputs.loss is mean NLL; multiply back to get sum
total_nll += outputs.loss.item() * tgt_len
total_tokens += tgt_len
prev_end = end
if end == seq_len:
break
return math.exp(total_nll / total_tokens)
Drill 3: McNemar's test for accuracy comparison
from scipy.stats import chi2
def mcnemar_test(correct_a, correct_b):
"""
McNemar's test for paired accuracy comparison.
correct_a, correct_b: boolean arrays [n_examples]
Returns: (chi2_stat, p_value)
"""
# Discordant pairs
b = ((correct_a) & (~correct_b)).sum() # A correct, B wrong
c = ((~correct_a) & (correct_b)).sum() # A wrong, B correct
# With continuity correction (Edwards)
chi2_stat = (abs(b - c) - 1) ** 2 / (b + c) if (b + c) > 0 else 0
p_value = 1 - chi2.cdf(chi2_stat, df=1)
return chi2_stat, p_value
Interview Prep — File 06: Behavioral STAR Stories
====================================================
Prepared STAR stories for each core competency of a Senior Staff ML Engineer.
Customize with your own specific numbers and outcomes.
Format per story:
S — Situation (context, role, scale)
T — Task (what specifically was needed)
A — Action (what YOU did, technical depth)
R — Result (quantified outcome, business impact)
Competency 1: Technical Leadership & Driving Cross-functional Impact
Story: "Tell me about a time you defined the technical direction for a major ML initiative"
S: I was the tech lead on a 6-person team tasked with deploying a next-gen vision model on Snapdragon 8 Gen 2 chips for real-time on-device inference. The product deadline was 6 months away; existing INT8 quantization gave 28% accuracy drop on our key task — unacceptable for production.
T: I needed to define a quantization strategy that met both the accuracy target (< 1% drop from FP32) and the latency target (< 12ms on the HTP) within the team's expertise and timeline.
A:
- Ran a systematic layer-by-layer sensitivity analysis using a cosine similarity probe (layers with CosSim < 0.99 got flagged). Found that 4 attention layers were responsible for 80% of the accuracy loss.
- Designed a mixed-precision strategy: INT4 for feed-forward layers (70% of parameters), FP16 for attention (sensitive), INT8 for embeddings.
- Introduced AWQ for the INT4 layers to handle activation outliers — reduced the W4A8 accuracy gap from 3.2% to 0.7%.
- Built an automated sensitivity + mixed-precision pipeline that ran overnight and produced a deployment artifact.
- Presented the strategy to VP Engineering with accuracy/latency/model-size Pareto frontier, got alignment on the FP16 attention exception.
R:
- Shipped on time with 0.6% accuracy delta (vs 28% with naive INT8).
- Latency: 9.4ms on HTP (21% under the 12ms target).
- The sensitivity pipeline became the company-wide standard for quantization decisions — adopted by 3 other teams.
- Model file size: 72% reduction (350MB → 98MB).
How to sell it: Emphasize the systematic, data-driven approach (not guessing), the cross-functional alignment (VP buy-in), and the artifact that outlasted the project.
Competency 2: Debugging Complex, Ambiguous Problems
Story: "Describe a time you diagnosed a subtle accuracy regression"
S: Three days before a major product release, model accuracy on a customer-facing benchmark unexpectedly dropped from 76.2% to 71.4% — a 4.8% drop that appeared overnight with no code changes.
T: Find the root cause within 48 hours, propose a fix, and either revert or patch without delaying the release.
A:
- First, verified the regression was real: re-ran evaluation twice with different seeds to rule out measurement noise. Confirmed it was consistent (±0.1%).
- Checked git blame on the evaluation script — found a silent tokenizer version bump from
transformers 4.38to4.40that changed the special token handling (BOS token was now doubled in 4.40 for this model family). - Wrote a minimal repro: 10-example test that showed the score difference was entirely explained by the tokenizer change (forward pass outputs were identical).
- Options: (a) pin tokenizer version, (b) fix evaluation harness, (c) re-quantize model for new tokenizer. Chose (b) — fix the harness to strip the duplicate BOS — since the model itself was fine.
- Added a tokenizer version assertion to CI to prevent silent future changes.
R:
- Found and fixed in 18 hours; release was not delayed.
- Added regression test that has caught 2 similar tokenizer-related issues in subsequent months.
- Wrote an internal post-mortem that became required reading for all eval harness contributors.
How to sell it: Show methodical hypothesis elimination. Interviewers love when you describe the dead ends too.
Competency 3: Delivering Results Under Constraints
Story: "Tell me about a project where you had to make significant technical trade-offs under time pressure"
S: Joining a 2-month sprint to optimize a transformer model for on-device deployment. I was given a quantized model that was 4× over the memory budget (1.2 GB actual vs 300 MB target) with no timeline extension possible.
T: Reduce the model to fit in memory while keeping accuracy within 2% of FP32 baseline.
A:
- Week 1 — profiled where parameters actually lived: 60% in feed-forward, 30% in attention, 10% embeddings. The 4B parameter model was too large even at INT4.
- Decided to do structured pruning to 70% sparsity on FFN layers (2:4 NVIDIA sparse format + NPU structured pruning for Snapdragon). This required a 3-day fine-tuning run.
- Week 2 — combined pruning with INT4 quantization (AWQ). Result: 285 MB, within target.
- Accuracy: 3.1% drop — over budget. Tracked it to pruning ratio being too aggressive.
- Week 3 — ran per-layer sensitivity analysis; found 8 FFN layers were contributing disproportionately to the drop. Reverted those to 50% sparsity.
- Final: 310 MB (+3% over target), 1.8% accuracy drop.
- Negotiated with PM: 310 MB was acceptable given the HW roadmap would add 64MB in next chip revision.
R:
- Shipped within the sprint: 310 MB, 1.8% accuracy delta.
- The pruning + INT4 recipe was documented and reused for 5 subsequent model deployments.
- Learned: always negotiate the target before optimizing, not after. (Said this directly in my feedback.)
Competency 4: Influence Without Authority
Story: "How have you influenced a technical decision you didn't own?"
S: A partner team was planning to adopt a simple round-to-nearest INT8 quantization approach for a 7B LLM deployment. Their accuracy estimate was "good enough" — but they hadn't measured on our specific distribution.
T: I believed their approach would fail on our domain-specific queries, but I had no direct authority over their roadmap.
A:
- Spent 3 hours running a targeted experiment: took 200 examples from our internal test set and measured accuracy with their INT8 scheme vs. GPTQ INT4. GPTQ INT4 was 2.1% more accurate AND 2× smaller.
- Requested 30 minutes at their team's tech review. Presented a one-page comparison: accuracy table, latency numbers, model size. No opinion — just data.
- Offered to contribute: "If you're open to it, I can add GPTQ support to your quantization pipeline in a week."
- They agreed to a 1-week experiment. I implemented GPTQ in their pipeline, validated on their test set.
- The GPTQ INT4 model became their deployment candidate.
R:
- 2.1% accuracy improvement in production.
- My contribution was acknowledged in their team's Q3 review.
- Established a pattern: cross-team quantization benchmarking before committing to a strategy.
How to sell it: Emphasize that you led with data, not opinion. Offered concrete help rather than just criticism.
Competency 5: Growing Others
Story: "How have you grown junior engineers technically?"
S: A new hire (2 years experience) was assigned to run model accuracy evaluations. They were producing results with high variance (±3%) because they were running without seeds and using different tokenizer configs.
T: Upskill them on rigorous eval methodology without micromanaging or demoralizing.
A:
- Code reviewed their eval script and left detailed comments — not "this is wrong" but "this particular seed issue caused a 12% variance in my experiment — here's the math."
- Shared the relevant sections from the lm_eval documentation and our internal best practices doc.
- Pair-programmed for 2 hours: walked through McNemar's test for statistical significance, why paired tests matter, how to set seeds correctly.
- Gave them ownership: "You're now the team's eval accuracy owner. When anyone runs an eval, you review the methodology."
- Monthly 1:1s to review their eval results together and discuss statistical interpretation.
R:
- Variance in their evaluations dropped from ±3% to ±0.15% within 2 months.
- They wrote the team's "Evaluation Best Practices" document that's now onboarding material.
- In their 6-month review, they were promoted to senior engineer 6 months ahead of schedule.
Competency 6: Handling Failure / Resilience
Story: "Tell me about a time a project failed and what you learned"
S: I led an effort to train a specialized code generation model using QLoRA to run on-device. After 4 weeks of training, the model performed well on held-out code, but completely failed on the production metric (user satisfaction, measured by human evaluation).
T: Understand the failure, decide whether to continue or pivot.
A:
- Root cause analysis: the training dataset was scraped from public GitHub (clean, structured code). Production queries were messy, partial code snippets with typos and context-less fragments.
- Distribution mismatch was the root cause — not the model architecture or the QLoRA setup.
- Organized a postmortem with the team: framed it as a data strategy failure, not a personal failure. Identified 3 things we'd do differently.
- Proposed a pivot: collect 10k examples of actual user queries (with consent), re-annotate with correct completions, retrain with 20% domain-specific data mixed in.
- Presented the pivot to leadership: here's what we learned, here's the revised plan, here's the revised timeline (+3 weeks).
R:
- The pivot worked: production human eval score went from 52% (original model) to 71%.
- I was recognized for the transparency and the clean postmortem process.
- Added "distribution match analysis" as a required step before any fine-tuning project.
What to emphasize: Taking responsibility without catastrophizing. Showing the systematic approach to root-causing and the proactive communication with leadership.
Quick STAR Bullets (for follow-up prompts)
"Tell me about a time you disagreed with leadership." → Story: Disagreed with a 2-week deadline for INT8 quantization that would've required skipping calibration data collection. Presented a data risk analysis (expected 4% accuracy drop vs 0.5% with proper calibration). Leadership extended the timeline by 1 week. Shipped with 0.6% accuracy delta.
"Describe a time you had to learn something quickly." → Story: Given 2 weeks to become proficient in Qualcomm AI Hub before a major partner demo. Systematically worked through all Hub tutorial notebooks, ran each example, built a custom pipeline integrating our internal model. Demo succeeded; became the team's AI Hub expert.
"Give an example of owning a process improvement." → Story: Noticed eval results were taking 8+ hours and blocking releases. Profiled bottleneck: redundant tokenization (re-tokenizing the same dataset for every model). Implemented a tokenized dataset cache. Evaluation time: 8 hours → 45 minutes. Adopted by the full ML platform team.
Preparation Checklist
- Practice each story out loud — aim for 2-3 minutes per story
- Have quantified numbers memorized: accuracy deltas, latency improvements, team size
- For each story, prepare a 30-second "elevator version" for follow-up
- Know the "failure" story cold — it's often the most memorable
- Ask yourself: "Would a senior engineer find this technically impressive?" — if yes, keep it
- Avoid: vague language ("we improved performance"), team credit without your role, stories that are pure process (no technical content)
System Design — Model Accuracy & AI Performance
Overview
This directory contains 5 full system design walkthroughs for the most common scenarios in a Senior Staff ML engineer interview at Qualcomm and similar companies. Each document follows the standard system design interview format: requirements → high-level design → deep dive → trade-offs → scale considerations.
Scenarios
| File | Scenario | Key Skills |
|---|---|---|
01-model-accuracy-regression-platform.md | CI/CD platform for model accuracy | eval harness, statistical testing, CI integration |
02-npu-graph-optimization-pipeline.md | End-to-end PyTorch → NPU compilation | compiler, ONNX, QNN, profiling |
03-quantization-accuracy-recovery-system.md | PTQ + QAT accuracy recovery service | quantization methods, calibration data management |
04-distributed-eval-platform.md | Multi-model, multi-device eval infrastructure | distributed systems, job scheduling, result storage |
05-model-perf-benchmarking-infra.md | Production benchmarking infrastructure | continuous benchmarking, A/B testing, Pareto tracking |
How to Practice System Design
- 30 minutes to design: cover requirements, API, data model, core algorithm, scale
- Never show only happy path: always discuss failure modes, monitoring, rollback
- Quantify everything: "1000 model evals/day" not "many evals"
- Know the trade-offs: at Senior Staff level, there is no single right answer — you need to articulate WHY you made each architectural choice
- Connect to hardware: at Qualcomm, every system design should mention how it handles NPU-specific constraints (static shapes, quantization, HTP fallbacks)
Senior Staff Differentiators
What distinguishes a Senior Staff system design from a Senior:
- Platform thinking: builds the system so other teams can use it, not just your team
- Observability first: metrics, alerts, and debugging are first-class, not afterthoughts
- Policy engine: accuracy regression policy is configurable, not hardcoded thresholds
- Multi-dimensional quality: latency + accuracy + energy + memory simultaneously, not just one axis
System Design 01 — Model Accuracy Regression Platform
Scenario: "Design a platform that automatically detects when a model's accuracy regresses after a code or optimization change. The platform must support 50+ models, run in CI, and notify engineers within 10 minutes of a change landing."
1. Requirements Clarification
Functional:
- Trigger accuracy evaluation on every PR merge to main
- Compare against stored baseline with statistical significance testing
- Support pluggable benchmark suites (MMLU, custom domain tasks)
- Send alerts (Slack, GitHub PR comment) when regression detected
- Store historical accuracy trends (for dashboards and root-cause analysis)
- Support multiple model families: LLMs, vision, audio
Non-functional:
- P95 eval completion < 10 minutes (requires fast eval mode)
- 99.9% availability for CI integration
- Scale to 50 model families × 10 bit-width configs = 500 eval jobs/day
- False positive rate < 5% (avoid blocking valid PRs)
Out of scope: Full model training, production serving metrics, human evaluation.
2. Capacity Estimates
- 500 eval jobs/day, each ~5 min (fast mode) = 2,500 GPU-minutes/day
- 3 × A100 GPU nodes = 4,320 GPU-minutes/day → ~1.7× headroom
- Storage: 500 jobs/day × 200 examples/task × 5 tasks × 4 bytes = 2 MB/job × 500 = 1 GB/day
- Historical data: 1 GB/day × 365 days = ~365 GB/year → standard object storage
3. High-Level Architecture
┌──────────────────────────────────────────────────────────┐
│ CI/CD Integration │
│ GitHub/GitLab webhooks → Job Queue → Eval Workers │
└──────────────────────────────────────────────────────────┘
↓ submit job ↑ results
┌──────────────────────────────────────────────────────────┐
│ Eval Service │
│ ┌─────────────┐ ┌────────────┐ ┌──────────────────┐ │
│ │ Task │ │ Model │ │ Comparison │ │
│ │ Registry │ │ Loader │ │ Engine │ │
│ └─────────────┘ └────────────┘ │ (McNemar's) │ │
│ └──────────────────┘ │
└──────────────────────────────────────────────────────────┘
↓ store ↑ baseline
┌──────────────────────────────────────────────────────────┐
│ Storage Layer │
│ ┌─────────────────┐ ┌───────────────────────────┐ │
│ │ Results DB │ │ Baseline Store │ │
│ │ (PostgreSQL) │ │ (S3/GCS per model) │ │
│ └─────────────────┘ └───────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
↓ alert
┌──────────────────────────────────────────────────────────┐
│ Alert Service │
│ Slack notifications, GitHub PR comments, PagerDuty │
└──────────────────────────────────────────────────────────┘
4. Core Components
4.1 Task Registry
class TaskRegistry:
"""Pluggable task registry — teams register their own tasks."""
def register(self, task_name: str, task_cls: Type[Task],
model_families: list[str]) -> None:
"""Register a task for a set of model families."""
def get_tasks_for_model(self, model_id: str, mode: str) -> list[Task]:
"""
mode = 'fast': ~5 min (200 examples per task)
mode = 'full': ~60 min (full test sets)
"""
4.2 Comparison Engine (Statistical Testing)
class ComparisonEngine:
def compare(
self,
baseline: PerExampleResults,
candidate: PerExampleResults,
policy: RegressionPolicy,
) -> ComparisonResult:
"""
For each task:
1. Run McNemar's test
2. Compute absolute accuracy delta
3. Apply policy: fail if p < policy.p_threshold AND |delta| > policy.delta_threshold
"""
@dataclass
class RegressionPolicy:
p_threshold: float = 0.05 # statistical significance
delta_threshold: float = 0.005 # 0.5% absolute minimum
tasks_requiring_all_pass: list[str] = field(default_factory=list)
tasks_requiring_any_pass: list[str] = field(default_factory=list)
4.3 Baseline Management
The baseline for each model is the latest main branch result. Strategy:
- On merge to
main: run full eval (60 min), store as new baseline - On PR: run fast eval (5 min), compare to baseline
- Baseline stored per (model_id, bit_width, quantization_method, task) tuple
- Immutable — once stored, a baseline is never overwritten (only new baselines added)
5. Fast Eval vs Full Eval
Fast eval must complete in < 10 minutes:
- Run 200 examples per task (calibrated to detect 2% absolute regression with p<0.05)
- Use stratified sampling (proportional to task subject distribution for MMLU)
- Run 3 tasks instead of 5 (drop the two least sensitive tasks)
- Use batch_size=32 (vs 64 for full) to reduce memory pressure and start faster
Full eval (nightly):
- All examples, all tasks
- Store results as new baseline if score is higher than current baseline
- Required before any production model release
6. Calibration of Fast Eval Thresholds
The fast eval uses only 200 examples per task. With 200 examples: $$\text{SE} = \sqrt{\frac{p(1-p)}{200}} \approx 0.035 \text{ (3.5%)}$$
To detect a 2% regression with 80% power at p<0.05:
- Need approximately 120 examples where models disagree
- With 200 examples and 50% overlap: expect 100 disagreements → marginal but workable for 2% regression
Recommendation: increase to 500 examples per task for reliable 1% regression detection.
7. Failure Modes and Mitigations
| Failure | Detection | Mitigation |
|---|---|---|
| GPU eval node failure | Heartbeat + timeout | Retry on different node; alert if all retries fail |
| Model loading failure | Exception in eval worker | Report as eval error (not accuracy failure); don't block PR |
| Calibration dataset drift | Periodic re-calibration check | Monthly: run baseline eval on new and old calibration set; compare |
| False positive regression | Post-mortem analysis | Weekly: review all failed CI checks; adjust thresholds if FP rate > 5% |
| Baseline contamination (buggy baseline) | Baseline audit | Before each new baseline: verify against previous 3 baselines; flag >5% jump |
8. Monitoring and Observability
Metrics to track:
- Eval job P95 latency (alert if > 10 min)
- False positive rate per week (alert if > 5%)
- Baseline staleness (alert if > 7 days without new baseline)
- GPU utilization per node (alert if < 50% — indicates queue backup)
Dashboard:
- Accuracy trend per model over last 90 days
- Pareto frontier (accuracy vs latency) updated daily
- Model health matrix: green/yellow/red per (model, task, bit-width)
9. Trade-offs
| Decision | Alternative | Why This Choice |
|---|---|---|
| McNemar's test | Z-test for proportions | McNemar accounts for correlation (same test examples) — more powerful for paired comparisons |
| PostgreSQL | ClickHouse | Complex joins needed for per-example analysis; ClickHouse is faster for analytics but harder for transactional writes |
| Fast eval (200 examples) | Full eval (14k examples) | 10-minute SLA requirement; calibrate to minimize false negatives for the 2% threshold |
| Per-model baselines | Shared baseline pool | Each model family has different accuracy distributions; shared baseline could mask model-specific regressions |
10. Scale Considerations
At 10× scale (500+ models):
- Introduce priority queues: production models get fast-lane eval; experimental models batch
- Shard the eval cluster by model family (LLM workers vs vision workers have different GPU requirements)
- Cache model weights across eval runs (large models take minutes to load; LRU cache on workers)
- Use gradient checkpointing or batch-size-1 inference for memory-constrained evals
System Design 02 — NPU Graph Optimization Pipeline
=====================================================
Full design document for an automated pipeline that takes a PyTorch FX graph
and produces an optimized, deployed artifact for Qualcomm NPU (HTP).
Problem Statement
Build an automated graph optimization and deployment pipeline that:
- Accepts any PyTorch model (FP32 or FP16)
- Applies graph-level optimizations (op fusion, layout transforms, quantization)
- Compiles to Qualcomm HTP (Hexagon Tensor Processor) binary
- Validates accuracy against FP32 reference
- Emits a production-ready
.dlcor.qnncacheartifact with benchmark report
Scale: 50+ model architectures, 3 hardware targets (Snapdragon 8 Gen 3, 888, 7 Gen 3), weekly pipeline runs.
Requirements
Functional
- Automated end-to-end: PyTorch → ONNX → QNN/SNPE → Validated artifact
- Per-layer accuracy analysis (cosine similarity, SNR)
- Mixed-precision support (per-layer FP16/INT8/INT4 assignment)
- AI Hub integration for cloud on-device profiling
- Artifact registry with model metadata, benchmark results, and reproducibility info
Non-Functional
- Pipeline SLA: < 4 hours per model
- Accuracy gate: CosSim ≥ 0.99 per layer, OR FP16 fallback for failing layers
- Latency gate: measured HTP latency within 10% of theoretical estimate
- Reproducibility: given same inputs + hardware, same output
Architecture Overview
┌──────────────────────────────────────────────────────────────────────────────┐
│ NPU Graph Optimization Pipeline │
└──────────────────────────────────────────────────────────────────────────────┘
[1. Ingestion]
PyTorch Model + Calibration Dataset (500 samples) + Config YAML
│
▼
[2. FX Graph Analysis]
- torch.export() → ExportedProgram
- Op coverage check: which ops HTP supports natively
- Identify unsupported ops → fallback candidates
- Build layer dependency graph
│
▼
[3. Graph Optimization Passes]
Pass 1: Op Fusion (Conv+BN+ReLU, Linear+GeLU, QKV projection merge)
Pass 2: Layout Transform (NCHW → NHWC for HTP efficiency)
Pass 3: Dead Code Elimination
Pass 4: Constant Folding (pre-compute static ops)
│
▼
[4. Quantization]
Mode A (PTQ): Collect activation histograms → calibrate scales → INT8/INT4
Mode B (Mixed Precision): Run sensitivity analysis → assign precision per layer
Mode C (QAT): Load pre-trained QAT checkpoint → extract fake-quant scales
│
▼
[5. ONNX Export & Verification]
torch.onnx.export(model, example_inputs, "model.onnx", opset=17)
onnxruntime validation: max output diff < 1e-4 vs PyTorch
onnx-simplifier + onnxoptimizer
│
▼
[6. Compilation]
Target: SNPE → snpe-onnx-to-dlc → model.dlc
Target: QNN → qnn-onnx-converter → qnn-context-binary-generator → model.bin
│
▼
[7. On-Device Validation (AI Hub)]
qai_hub.submit_compile_job(source_model=model.onnx, device="Snapdragon 8 Gen 3")
qai_hub.submit_profile_job(model=compiled_model, device=..., input_specs=...)
Metrics: latency (median, p95), memory peak, per-layer time
│
▼
[8. Accuracy Gate]
For each layer: compute cosine_similarity(fp32_out, int8_out)
If CosSim < 0.99: mark layer as "sensitive" → re-quantize at FP16
If overall task accuracy drop > threshold: FAIL pipeline, alert
│
▼
[9. Artifact Registration]
Store: model.dlc + metadata.json + benchmark_results.json + accuracy_report.json
Metadata: model_sha, calibration_sha, hardware_target, precision_map, pipeline_version
Key Components
2. FX Graph Analysis
from torch.export import export
def analyze_graph(model, example_inputs):
exported = export(model, example_inputs)
fx_graph = exported.graph_module
# Check op support
unsupported = []
for node in fx_graph.graph.nodes:
if node.op == "call_function":
if node.target not in HTP_SUPPORTED_OPS:
unsupported.append((node.name, node.target))
return {
"n_ops": len(list(fx_graph.graph.nodes)),
"unsupported_ops": unsupported,
"coverage": 1 - len(unsupported) / max(1, n_ops),
}
HTP unsupported ops (common): torch.nn.functional.scaled_dot_product_attention (before SDPA optimization), custom Python autograd functions, dynamic shape operators without static shapes.
4. Sensitivity Analysis for Mixed Precision
def compute_per_layer_sensitivity(model, val_data, baseline_acc, bits_map):
"""
Returns: {layer_name: accuracy_drop_when_quantized}
"""
sensitivity = {}
for name, module in model.named_modules():
if not isinstance(module, nn.Linear):
continue
# Quantize only this layer
model_copy = copy.deepcopy(model)
layer = get_nested_attr(model_copy, name)
quantize_layer(layer, bits=bits_map.get(name, 8))
acc = evaluate(model_copy, val_data)
sensitivity[name] = baseline_acc - acc
return sensitivity
Mixed precision assignment rule:
- Sensitivity > 1.0%: FP16
- Sensitivity 0.3-1.0%: INT8
- Sensitivity < 0.3%: INT4
This typically results in: 5-10% of layers at FP16, 20% at INT8, 70% at INT4.
6. ONNX Export Best Practices
Common export failures and fixes:
| Failure | Root Cause | Fix |
|---|---|---|
TypeError: Only Tensors... | Python-level control flow in forward() | torch.jit.trace first, or rewrite with tensor ops |
| Shape mismatch after export | Dynamic shapes not declared | torch.onnx.export(..., dynamic_axes={"input": {0: "batch"}}) |
| Unsupported op | Custom extension or experimental op | Register custom ONNX symbolic or decompose |
| Precision mismatch | FP64 default for some ops | Cast inputs explicitly to FP16/FP32 |
7. AI Hub Integration
import qai_hub as hub
def deploy_and_profile(onnx_path, device_name, input_specs):
"""Submit compile + profile jobs to Qualcomm AI Hub."""
# Compile to on-device format
compile_job = hub.submit_compile_job(
model=hub.upload_model(onnx_path),
device=hub.Device(device_name),
input_specs=input_specs,
options="--target_runtime qnn_lib_aarch64_android"
)
compiled_model = compile_job.get_target_model()
# Profile latency
profile_job = hub.submit_profile_job(
model=compiled_model,
device=hub.Device(device_name),
input_specs=input_specs,
)
profile = profile_job.download_profile()
return {
"latency_ms": profile["execution_summary"]["estimated_inference_time"] / 1000,
"peak_memory_mb": profile["execution_summary"]["inference_memory_peak_range"][0] / (1024**2),
"n_ops": profile["execution_summary"]["total_ops"],
"htp_op_percentage": profile["execution_summary"].get("htp_ops_percentage", 0),
}
8. Accuracy Validation
Layer-by-layer cosine similarity:
def validate_layer_accuracy(model_fp32, model_int8, calibration_inputs):
"""
Attach hooks to both models, compare intermediate activations.
Flag layers with CosSim < 0.99 as problematic.
"""
activations_fp32 = {}
activations_int8 = {}
def make_hook(name, store):
def hook(module, input, output):
store[name] = output.detach()
return hook
# Register hooks
hooks = []
for name, module in model_fp32.named_modules():
if isinstance(module, (nn.Linear, nn.Conv2d)):
hooks.append(module.register_forward_hook(make_hook(name, activations_fp32)))
# Run both models
with torch.no_grad():
model_fp32(calibration_inputs)
model_int8(calibration_inputs)
# Compute CosSim
results = {}
for name in activations_fp32:
if name in activations_int8:
cos_sim = F.cosine_similarity(
activations_fp32[name].flatten(),
activations_int8[name].flatten(),
dim=0
).item()
results[name] = {
"cos_sim": cos_sim,
"status": "PASS" if cos_sim > 0.99 else "FAIL"
}
# Cleanup
for h in hooks:
h.remove()
return results
Failure Modes & Mitigations
| Failure | Detection | Mitigation |
|---|---|---|
| ONNX export fails | Build-time error | Pre-check with torch.onnx.dynamo_export; decompose custom ops |
| HTP op coverage < 80% | Analysis step | Profile CPU/GPU fallback cost; set threshold per model type |
| Accuracy gate fails | Layer-by-layer CosSim | Automatic FP16 upgrade for failing layers; re-run pipeline |
| AI Hub rate limit | HTTP 429 from Hub API | Exponential backoff; queue jobs; use local SNPE SDK as fallback |
| Non-deterministic results | Repeated runs differ > 0.1% | Fix batch normalization in eval mode; set calibration seed |
| Model too large for HTP | Memory profiler report | Enable layer streaming; reduce context length; prune first |
Performance Characteristics
| Operation | Typical Time | Hardware |
|---|---|---|
| torch.export() + graph analysis | 2-5 min | CPU |
| Sensitivity analysis (50 layers) | 15-30 min | 1× A100 |
| Calibration + INT8/INT4 quantization | 5-10 min | 1× A100 |
| ONNX export + simplification | 2-3 min | CPU |
| Compilation (snpe-onnx-to-dlc) | 10-20 min | CPU |
| AI Hub compile job | 15-30 min | Cloud |
| AI Hub profile job | 5-10 min | Cloud + Device |
| Accuracy validation | 5-15 min | 1× A100 |
| Total pipeline | ~2-3 hours |
Artifact Schema
{
"model_id": "llama-7b-instruct-v3",
"model_sha": "a3f9b2...",
"pipeline_version": "2.4.1",
"hardware_target": "Snapdragon 8 Gen 3",
"quantization": {
"strategy": "mixed_precision",
"default_bits": 4,
"fp16_layers": ["model.layers.0.self_attn.q_proj", "..."],
"int8_layers": ["model.layers.1.self_attn.q_proj", "..."],
"calibration_samples": 500,
"calibration_sha": "f3e2a1..."
},
"accuracy": {
"task": "MMLU-abstract_algebra",
"fp32_baseline": 0.723,
"quantized": 0.716,
"delta": -0.007,
"gate": "PASS"
},
"latency": {
"median_ms": 9.4,
"p95_ms": 11.2,
"peak_memory_mb": 285,
"htp_op_coverage": 0.94
},
"artifacts": {
"dlc": "s3://models/llama-7b-v3-snap8gen3-int4.dlc",
"profile_json": "s3://models/llama-7b-v3-profile.json"
}
}
System Design 03 — Quantization Accuracy Recovery System
============================================================
Design for an automated system that detects, diagnoses, and recovers
from accuracy loss caused by quantization.
Problem Statement
Quantizing LLMs to INT8/INT4 often causes 1-8% accuracy regression. Build a system that:
- Detects which layers are causing the regression
- Automatically selects a recovery strategy (mixed precision, ADAROUND, QAT)
- Applies the recovery with minimal human intervention
- Validates the result and packages the final artifact
System Overview
Input: FP32 model + val_dataset + accuracy_target + latency_budget
│
▼
[Stage 1: Baseline Assessment]
- Compute FP32 accuracy on val_dataset
- Compute naive INT8/INT4 accuracy
- If gap < tolerance → DONE (no recovery needed)
- Else → enter recovery pipeline
│
▼
[Stage 2: Diagnosis]
- Per-layer sensitivity analysis
- Activation range / outlier detection
- Weight distribution analysis (bimodal, heavy-tailed)
│
▼
[Stage 3: Strategy Selection] ← Decision Engine
│
┌───────────────┼───────────────┐
▼ ▼ ▼
[Mixed [ADAROUND / [QAT]
Precision] SmoothQuant]
│ │ │
└───────────────┴───────────────┘
│
▼
[Stage 4: Validation]
- Re-evaluate accuracy after recovery
- If still failing: escalate to QAT or alert engineer
│
▼
[Stage 5: Artifact Registry]
Stage 2: Diagnosis Engine
2a. Per-Layer Sensitivity Analysis
def sensitivity_analysis(model, val_data, baseline_acc, target_bits=8, n_examples=200):
"""
Quantize each layer independently, measure accuracy drop.
Returns sorted list of (layer_name, accuracy_drop).
"""
sensitivity = {}
model.eval()
for name, module in model.named_modules():
if not isinstance(module, QuantizableLayer):
continue
# Temporarily quantize just this layer
with TemporaryQuantize(model, name, bits=target_bits):
acc = fast_evaluate(model, val_data, n_examples=n_examples)
sensitivity[name] = baseline_acc - acc
return sorted(sensitivity.items(), key=lambda x: x[1], reverse=True)
Interpretation:
- Layer with sensitivity > 2%: "critical" → must be FP16 or FP32
- Layer with sensitivity 0.5-2%: "moderate" → INT8 preferred
- Layer with sensitivity < 0.5%: "low" → safe for INT4
2b. Outlier Detection
def detect_activation_outliers(model, calibration_data, threshold_sigma=5.0):
"""
Identify layers with activation outliers that inflate quantization scale.
Returns: {layer_name: {"outlier_fraction": float, "max_sigma": float}}
"""
outlier_report = {}
def make_hook(name):
def hook(module, input, output):
x = output.detach().float()
mean = x.mean()
std = x.std()
sigma_max = (x.abs() - mean).max() / std
outlier_frac = (x.abs() > threshold_sigma * std).float().mean().item()
outlier_report[name] = {
"outlier_fraction": outlier_frac,
"max_sigma": sigma_max.item()
}
return hook
hooks = [m.register_forward_hook(make_hook(n))
for n, m in model.named_modules() if isinstance(m, nn.Linear)]
with torch.no_grad():
for batch in calibration_data:
model(batch)
break # one batch is enough for outlier profiling
for h in hooks:
h.remove()
return {k: v for k, v in outlier_report.items() if v["outlier_fraction"] > 0.001}
Stage 3: Strategy Decision Engine
class StrategySelector:
def __init__(self, accuracy_gap, latency_budget_ms, available_gpu_hours):
self.accuracy_gap = accuracy_gap # % accuracy drop from PTQ
self.latency_budget = latency_budget_ms
self.gpu_hours = available_gpu_hours
def select(self, sensitivity_report, outlier_report):
strategies = []
# Strategy 1: Mixed Precision (always try first — cheapest)
n_critical = sum(1 for _, drop in sensitivity_report if drop > 1.0)
mixed_prec_cost = n_critical * 0.1 # FP16 layers add ~10% latency
if mixed_prec_cost < self.latency_budget * 0.1:
strategies.append(("mixed_precision", cost=low, recovery_potential=0.5-1.5%))
# Strategy 2: SmoothQuant (if outliers detected)
n_outlier_layers = len(outlier_report)
if n_outlier_layers > 3 and self.accuracy_gap > 1.5:
strategies.append(("smoothquant", cost=medium, recovery_potential=1.0-2.0%))
# Strategy 3: ADAROUND (if weight rounding is suboptimal)
if self.accuracy_gap > 2.0 and self.gpu_hours >= 2:
strategies.append(("adaround", cost=2 GPU hours, recovery_potential=0.5-1.0%))
# Strategy 4: QAT (nuclear option, highest recovery)
if self.accuracy_gap > 3.0 and self.gpu_hours >= 20:
strategies.append(("qat", cost=20-50 GPU hours, recovery_potential=2.0-5.0%))
# Pick lowest-cost strategy with sufficient recovery potential
for strategy, cost, potential in sorted(strategies, key=lambda x: x[1]):
if potential >= self.accuracy_gap * 0.8:
return strategy
return "alert_engineer" # no automatic solution
Stage 3a: Mixed Precision Application
def apply_mixed_precision(model, sensitivity_report, target_bits=4):
"""
Assign INT4/INT8/FP16 per layer based on sensitivity threshold.
"""
precision_map = {}
for name, drop in sensitivity_report:
if drop > 1.0:
precision_map[name] = "fp16"
elif drop > 0.3:
precision_map[name] = "int8"
else:
precision_map[name] = f"int{target_bits}"
# Apply
for name, precision in precision_map.items():
layer = get_nested_attr(model, name)
if precision == "fp16":
layer.to(torch.float16)
else:
bits = int(precision.replace("int", ""))
quantize_layer_inplace(layer, bits=bits)
return model, precision_map
Typical result: top 8% of layers at FP16 (most sensitive), 22% at INT8, 70% at INT4.
Stage 3b: SmoothQuant
def apply_smoothquant(model, calibration_data, alpha=0.5):
"""
Apply SmoothQuant activation-weight migration.
For each Linear layer: W' = W * diag(s), x' = x / diag(s)
Where s_j = max(|x_j|)^alpha / max(|W_j|)^(1-alpha)
The s^{-1} factor is folded into the preceding LayerNorm.
"""
# Collect activation scales
act_scales = {}
def make_hook(name):
def hook(module, input, output):
act_scales[name] = input[0].detach().abs().max(dim=0).values
return hook
hooks = []
for name, module in model.named_modules():
if isinstance(module, nn.Linear):
hooks.append(module.register_forward_hook(make_hook(name)))
with torch.no_grad():
for batch in calibration_data:
model(batch)
for h in hooks:
h.remove()
# Apply smoothing
for name, module in model.named_modules():
if isinstance(module, nn.Linear) and name in act_scales:
a_max = act_scales[name] # [in_features]
w_max = module.weight.data.abs().max(dim=0).values # [in_features]
s = a_max.pow(alpha) / w_max.pow(1 - alpha)
s = s.clamp(min=1e-4)
# Scale weights up by s
module.weight.data *= s.unsqueeze(0)
# Find preceding LayerNorm and scale it down by s
# (This requires knowing the model topology)
# ... (model-specific implementation)
return model
Stage 3c: ADAROUND
Key algorithm (Nagel et al., 2020):
$$\min_{v \in {0,1}^{d}} |WX - \hat{W}X|_F^2 + \lambda \sum_i h(\hat{v}_i)$$
where $\hat{W} = (\lfloor W/\delta \rfloor + v) \cdot \delta$ and $h$ is a regularizer that pushes $v$ to be binary.
def adaround_layer(W, X, bits=4, n_iters=10000, lr=1e-3, reg_strength=0.01):
"""
Optimize rounding decisions v for weight matrix W given input activations X.
W: [out, in], X: [n_calib, in]
"""
scale, _ = compute_qparams_minmax(W, bits, scheme="symmetric", per_channel=True)
# Initialize v based on fractional part
W_floor = torch.floor(W / scale.unsqueeze(1))
v_raw = W / scale.unsqueeze(1) - W_floor # values in [0, 1)
# Learnable parameter: sigmoid(v_raw) → v ∈ (0, 1)
v = nn.Parameter(torch.log(v_raw / (1 - v_raw + 1e-8))) # inverse sigmoid init
optimizer = torch.optim.Adam([v], lr=lr)
for step in range(n_iters):
# Soft rounding: sigmoid(v) approaches {0, 1} over training
v_soft = torch.sigmoid(v)
W_q = (W_floor + v_soft) * scale.unsqueeze(1)
# Reconstruction loss
loss_rec = ((W @ X.T) - (W_q @ X.T)).pow(2).mean()
# Regularization: push v to binary (0 or 1)
# h(v) = 1 - (2v - 1)^{2β}, β increases over training
beta = 2.0 + (step / n_iters) * 18.0 # anneal beta from 2 to 20
loss_reg = reg_strength * (1 - (2 * v_soft - 1).pow(2 * beta).abs()).sum()
loss = loss_rec + loss_reg
optimizer.zero_grad()
loss.backward()
optimizer.step()
# Binarize final v
v_binary = (torch.sigmoid(v.detach()) > 0.5).float()
W_adaround = (W_floor + v_binary) * scale.unsqueeze(1)
return W_adaround
Expected recovery: ~0.3-0.8% accuracy improvement over round-to-nearest on INT4.
Stage 3d: QAT (Mini Fine-tuning)
def run_qat(model, train_data, val_data, n_steps=5000, lr=1e-4):
"""
Quantization-Aware Training: insert fake-quant nodes and fine-tune.
Uses STE (straight-through estimator) for gradient through quantization.
"""
# Insert fake-quant nodes
model = torch.quantization.prepare_qat(model.train())
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=n_steps)
best_acc = 0
for step, batch in enumerate(cycle(train_data)):
if step >= n_steps:
break
loss = model(**batch).loss
loss.backward()
optimizer.step()
optimizer.zero_grad()
scheduler.step()
if step % 500 == 0:
acc = evaluate(model, val_data)
if acc > best_acc:
best_acc = acc
torch.save(model.state_dict(), "qat_best.pt")
# Load best and convert to INT8
model.load_state_dict(torch.load("qat_best.pt"))
model = torch.quantization.convert(model.eval())
return model
QAT guidelines:
- Start from the PTQ model (not FP32) — preserves knowledge
- LR = 1% to 10% of original training LR
- Steps = 1% to 5% of original training steps
- Use cosine LR schedule with warmup
- Monitor both QAT loss and validation accuracy
Decision Matrix
| Accuracy Gap | Outliers | GPU Budget | → Strategy |
|---|---|---|---|
| < 1% | — | — | Mixed precision only |
| 1-2% | Yes | 2h | SmoothQuant + mixed precision |
| 1-2% | No | 2h | ADAROUND + mixed precision |
| 2-4% | Yes | 20h | SmoothQuant + ADAROUND |
| > 4% | — | 50h | QAT |
| Any | — | 0h | FP16 (no quantization) |
Monitoring & SLA
- Recovery SLA: Complete recovery pipeline in < 6 GPU-hours for INT8, < 30 GPU-hours for INT4 QAT
- Escalation: If no strategy achieves target accuracy, open engineering ticket with full sensitivity report
- Weekly audit: Re-run accuracy validation on all deployed quantized models; alert if accuracy drifts > 0.5% from acceptance criteria (can happen due to evaluation dataset distribution shift)
System Design 04 — Distributed Offline Evaluation Platform
============================================================
Design a scalable platform for running large-scale model evaluations
across hundreds of model checkpoints and dozens of benchmarks.
Problem Statement
Build an offline evaluation platform that:
- Evaluates 200+ model checkpoints per week against 60+ benchmarks
- Supports custom task registration (beyond lm_eval built-ins)
- Provides reproducible, versioned evaluation results
- Detects accuracy regressions automatically
- Scales from a single laptop to a 100-GPU cluster
Scale target:
- 200 models × 60 benchmarks × 1000 examples = 12M eval calls/week
- At 100ms/call = 1,200,000 GPU-seconds = 333 GPU-hours/week
- Need: 50-100 GPUs running continuously (with ~70% utilization)
Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ Distributed Eval Platform │
└─────────────────────────────────────────────────────────────────────────────┘
[Model Registry] ──────────────────────────────────────────────────────────────
- Stores: model checkpoint (S3), metadata.json, sha256 fingerprint
- Triggers: "new model event" on every push to registry
[Benchmark Registry] ─────────────────────────────────────────────────────────
- MMLU (57 subjects), HellaSwag, ARC, WinoGrande, GSM8K, HumanEval
- Custom tasks (registered via Python @task_registry.register decorator)
- Versioned: each benchmark has a version hash (dataset + prompt format)
[Job Scheduler] ─────────────────────────────────────────────────────────────
Airflow DAG (weekly trigger + event trigger):
1. Fetch all new/changed model checkpoints
2. Cross with all benchmarks → (model, benchmark) task matrix
3. Filter: skip already-cached evaluations (check model_sha + benchmark_version)
4. Submit tasks to Ray cluster
[Ray Eval Workers] ──────────────────────────────────────────────────────────
Head node: Airflow + job management
Worker nodes: 50-100 A10G GPUs
Per worker task:
1. Download model from S3 (cached on worker)
2. Load benchmark examples (from NFS cache)
3. Run evaluation (batched inference)
4. Write results to Results DB
[Results Database] ──────────────────────────────────────────────────────────
PostgreSQL + TimescaleDB (for time-series accuracy tracking)
[Regression Detector] ────────────────────────────────────────────────────────
- Compares new results vs. 7-day rolling baseline per (model_family, benchmark)
- Triggers: accuracy drop > 1% with p < 0.05 → Slack alert + Jira ticket
[Dashboard] ─────────────────────────────────────────────────────────────────
Grafana + custom frontend
- Accuracy over time per model family
- Pareto frontier: accuracy vs. latency vs. model size
- Regression heatmap
Database Schema
-- Model registry
CREATE TABLE models (
id BIGSERIAL PRIMARY KEY,
model_id TEXT UNIQUE NOT NULL,
model_sha CHAR(64) NOT NULL, -- sha256 of checkpoint
s3_path TEXT NOT NULL,
family TEXT, -- e.g., "llama-3", "gemma-2"
n_params_b FLOAT,
registered_at TIMESTAMPTZ DEFAULT NOW()
);
-- Benchmark registry
CREATE TABLE benchmarks (
id BIGSERIAL PRIMARY KEY,
benchmark_name TEXT NOT NULL,
benchmark_version CHAR(16) NOT NULL, -- version hash
dataset_path TEXT,
n_examples INT,
primary_metric TEXT DEFAULT 'accuracy',
UNIQUE(benchmark_name, benchmark_version)
);
-- Evaluation results (hot path: partitioned by date)
CREATE TABLE eval_results (
id BIGSERIAL,
model_sha CHAR(64) NOT NULL,
benchmark_name TEXT NOT NULL,
benchmark_version CHAR(16) NOT NULL,
n_correct INT NOT NULL,
n_total INT NOT NULL,
accuracy FLOAT NOT NULL GENERATED ALWAYS AS (n_correct::float / n_total) STORED,
eval_time_s FLOAT,
gpu_type TEXT,
evaluated_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (id, evaluated_at)
) PARTITION BY RANGE (evaluated_at);
CREATE INDEX ON eval_results (model_sha, benchmark_name);
CREATE UNIQUE INDEX ON eval_results (model_sha, benchmark_name, benchmark_version); -- dedup
-- Baseline cache (updated weekly)
CREATE TABLE accuracy_baselines (
model_family TEXT NOT NULL,
benchmark_name TEXT NOT NULL,
baseline_accuracy FLOAT NOT NULL,
computed_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (model_family, benchmark_name)
);
Eval Worker Implementation
import ray
import torch
from pathlib import Path
@ray.remote(num_gpus=1)
class EvalWorker:
def __init__(self, model_cache_dir="/tmp/model_cache"):
self.model_cache_dir = Path(model_cache_dir)
self.model_cache_dir.mkdir(exist_ok=True)
self._loaded_model_sha = None
self._model = None
self._tokenizer = None
def _load_model_if_needed(self, model_sha, s3_path):
"""Lazy load model; reuse across tasks for the same checkpoint."""
if self._loaded_model_sha != model_sha:
local_path = self.model_cache_dir / model_sha
if not local_path.exists():
download_from_s3(s3_path, local_path)
self._model = load_model(local_path)
self._tokenizer = load_tokenizer(local_path)
self._loaded_model_sha = model_sha
def run_eval(self, model_sha, s3_path, benchmark_name, n_examples=1000):
"""Run a single (model, benchmark) evaluation."""
self._load_model_if_needed(model_sha, s3_path)
task = task_registry.get(benchmark_name)
examples = task.get_examples(max_examples=n_examples)
harness = EvalHarness(self._model, self._tokenizer, device="cuda")
result = harness.evaluate(task, max_examples=n_examples)
return {
"model_sha": model_sha,
"benchmark": benchmark_name,
"n_correct": int(result.accuracy * result.n_examples),
"n_total": result.n_examples,
"accuracy": result.accuracy,
}
Caching Strategy
Three levels of caching:
Level 1: Result cache (skip re-evaluation)
- Key:
(model_sha, benchmark_name, benchmark_version) - If result exists in DB: skip evaluation entirely
- Typical skip rate: 60-70% (most models unchanged week-to-week)
Level 2: Model cache (avoid re-downloading)
- Workers keep last 3 models in local NVMe storage (LRU eviction)
- Model download: 15-30 GB at 1 GB/s = 15-30s; cached hit = 1-2s
- Cache hit rate: ~80% (same model across multiple benchmarks)
Level 3: Tokenized dataset cache
- Pre-tokenize all benchmark examples once; store as
.ptfiles - Avoids repeated tokenization (saves ~5-10% eval time)
- Invalidated when benchmark version changes
Regression Detection Algorithm
def detect_regression(model_id, model_family, new_results, db, window_days=7):
"""
Detect accuracy regressions using McNemar's test.
new_results: {benchmark_name: accuracy}
"""
regressions = []
for benchmark, new_acc in new_results.items():
# Fetch baseline (best accuracy in last window_days)
baseline = db.query("""
SELECT AVG(accuracy) as baseline_acc
FROM eval_results
WHERE model_sha IN (
SELECT model_sha FROM models
WHERE family = %s
AND registered_at > NOW() - INTERVAL '%s days'
)
AND benchmark_name = %s
""", (model_family, window_days, benchmark))
if baseline is None:
continue
delta = new_acc - baseline
# Simple threshold check (paired statistical test requires per-example data)
if delta < -0.01: # > 1% absolute drop
regressions.append({
"benchmark": benchmark,
"baseline": baseline,
"new": new_acc,
"delta": delta,
})
return regressions
Scalability Analysis
| Scale | Models/week | Benchmarks | GPU-hours/week | GPUs needed |
|---|---|---|---|---|
| Small | 20 | 10 | 5.6 | 4 |
| Medium | 100 | 30 | 83 | 15 |
| Large | 200 | 60 | 333 | 50 |
| XL | 500 | 100 | 1,389 | 100+ |
Scaling strategies:
- Horizontal: Add more GPU workers (Ray auto-scales)
- Model sharing: Load once, run all benchmarks → amortize load time
- Parallelism: 60 benchmarks × 50 workers = 1 benchmark per worker simultaneously
- Early termination: If model is clearly worse (first 100 examples), skip rest
- Tiered evaluation: Fast tier (100 examples, 3 benchmarks) → full eval if passes
Fault Tolerance
| Failure | Detection | Recovery |
|---|---|---|
| Worker GPU OOM | CUDA OOM exception | Retry with smaller batch; fallback to CPU |
| S3 download failure | HTTP error | Retry 3× with exponential backoff |
| Model loading corruption | torch.load exception | Redownload from S3; invalidate local cache |
| DB write failure | DB connection error | Write to local queue; replay later |
| Airflow scheduler failure | Watchdog process | Restart Airflow; incomplete jobs restart cleanly |
| Ray head node failure | Health check | Redeploy head node; workers reconnect |
System Design 05 — Model Performance Benchmarking Infrastructure
================================================================
Design a reproducible, standardized infrastructure for measuring
ML model performance: latency, throughput, memory, and efficiency.
Problem Statement
Build a benchmarking infrastructure that:
- Provides reproducible latency/throughput measurements across hardware targets
- Supports GPU (A100, H100, 4090), edge (Snapdragon 8 Gen 3), and CPU
- Enables apples-to-apples comparison across models, frameworks, and backends
- Integrates into CI/CD to catch performance regressions
- Publishes reproducible benchmark artifacts
Scale: 50+ model variants, 10+ hardware targets, run weekly and on every PR.
Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ Model Performance Benchmarking Infrastructure │
└─────────────────────────────────────────────────────────────────────────────┘
[Benchmark Spec]
- Model: model_id, checkpoint_sha
- Input shapes: batch sizes, sequence lengths
- Backend: torch, torch.compile, onnxruntime, TensorRT, SNPE, QNN
- Hardware: GPU (A100), Edge (Snap 8 Gen 3), CPU
- Metrics: latency (p50/p95/p99), throughput (req/s), memory (peak HBM), power (mW)
[Benchmark Runner]
1. Provision hardware via hardware pool
2. Load model in specified backend
3. Warm up (N=20 iterations)
4. Measure (N=1000 iterations, record all)
5. Compute statistics: median, p95, p99, stddev, CV
6. Collect system metrics: GPU utilization, memory, power
7. Write to Results DB + publish artifact
[Hardware Pool]
GPU cluster (A100, H100): SLURM or Kubernetes managed
Edge farm: physical Snapdragon devices + AI Hub cloud
CPU farm: ARM64 + x86 cloud VMs
[CI Integration]
PR → fast benchmark (batch=1, seqlen=512, 50 iterations)
Weekly → full benchmark matrix (all batch sizes, all seq lengths)
[Regression Detector]
Compare new vs. baseline: alert if p50 latency increases > 5%
[Dashboard]
Grafana: latency over time, efficiency radar, hardware comparison
Measurement Protocol
Latency Measurement (GPU)
def benchmark_latency_gpu(model, inputs, n_warmup=20, n_measure=1000):
"""
Accurate GPU latency measurement using CUDA events.
Returns: dict with latency statistics in milliseconds.
"""
model.eval()
# Warmup (JIT compilation, caching)
with torch.no_grad():
for _ in range(n_warmup):
_ = model(*inputs)
torch.cuda.synchronize()
# Measurement
times_ms = []
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
with torch.no_grad():
for _ in range(n_measure):
start.record()
_ = model(*inputs)
end.record()
torch.cuda.synchronize()
times_ms.append(start.elapsed_time(end))
times_ms.sort()
n = len(times_ms)
return {
"p50_ms": times_ms[n // 2],
"p95_ms": times_ms[int(n * 0.95)],
"p99_ms": times_ms[int(n * 0.99)],
"mean_ms": sum(times_ms) / n,
"stddev_ms": (sum((t - sum(times_ms)/n)**2 for t in times_ms) / n) ** 0.5,
"cv": (sum((t - sum(times_ms)/n)**2 for t in times_ms) / n) ** 0.5 / (sum(times_ms) / n),
}
Throughput Measurement
def benchmark_throughput(model, inputs, target_duration_s=30):
"""
Measure sustained throughput by running for a fixed duration.
More accurate than latency × batch_size for batched inference.
"""
model.eval()
batch_size = inputs[0].shape[0] if hasattr(inputs[0], 'shape') else 1
# Warmup
with torch.no_grad():
for _ in range(10):
model(*inputs)
torch.cuda.synchronize()
n_iters = 0
t_start = time.perf_counter()
with torch.no_grad():
while time.perf_counter() - t_start < target_duration_s:
model(*inputs)
n_iters += 1
torch.cuda.synchronize()
total_time = time.perf_counter() - t_start
return {
"throughput_req_per_s": n_iters * batch_size / total_time,
"throughput_tokens_per_s": None, # set if applicable
"n_iters": n_iters,
"duration_s": total_time,
}
Memory Measurement
def measure_peak_memory(model, inputs):
"""Measure peak GPU memory during inference."""
if not torch.cuda.is_available():
return {"peak_mb": 0, "allocated_mb": 0}
torch.cuda.reset_peak_memory_stats()
torch.cuda.synchronize()
with torch.no_grad():
_ = model(*inputs)
torch.cuda.synchronize()
return {
"peak_mb": torch.cuda.max_memory_allocated() / (1024 ** 2),
"allocated_mb": torch.cuda.memory_allocated() / (1024 ** 2),
"reserved_mb": torch.cuda.memory_reserved() / (1024 ** 2),
}
Benchmark Matrix
Dimension 1: Model sizes
- 1B, 3B, 7B, 13B, 70B parameter models
Dimension 2: Batch sizes
- 1 (online serving), 8, 32, 128 (batch serving)
Dimension 3: Sequence lengths
- 128, 512, 2048, 8192 tokens
Dimension 4: Backends
| Backend | Use Case |
|---|---|
| PyTorch eager | Baseline |
torch.compile(backend="inductor") | GPU optimization |
torch.compile(backend="trt") | TensorRT |
| ONNX Runtime (CUDA) | Cross-framework |
| TensorRT directly | Max GPU perf |
| SNPE/QNN (HTP) | Snapdragon NPU |
| llama.cpp (CPU/Metal) | Edge CPU |
Dimension 5: Precision
- FP32, FP16, BF16, INT8, INT4 (W4A8, W4A16)
Results Database Schema
CREATE TABLE benchmark_runs (
id BIGSERIAL PRIMARY KEY,
run_id UUID NOT NULL DEFAULT gen_random_uuid(),
model_sha CHAR(64) NOT NULL,
model_id TEXT NOT NULL,
backend TEXT NOT NULL,
hardware TEXT NOT NULL,
batch_size INT NOT NULL,
seq_len INT NOT NULL,
precision TEXT NOT NULL,
-- Latency (ms)
latency_p50_ms FLOAT,
latency_p95_ms FLOAT,
latency_p99_ms FLOAT,
latency_cv FLOAT, -- coefficient of variation
-- Throughput
throughput_req_s FLOAT,
throughput_tok_s FLOAT,
-- Memory
peak_memory_mb FLOAT,
-- Efficiency
flop_per_s FLOAT,
roofline_efficiency FLOAT, -- measured / attainable
-- Metadata
gpu_model TEXT,
cuda_version TEXT,
torch_version TEXT,
benchmark_hash CHAR(16), -- hash of benchmark spec (for exact repro)
measured_at TIMESTAMPTZ DEFAULT NOW()
);
-- View: latest results per (model, hardware, batch, precision)
CREATE VIEW latest_benchmarks AS
SELECT DISTINCT ON (model_id, hardware, batch_size, precision)
*
FROM benchmark_runs
ORDER BY model_id, hardware, batch_size, precision, measured_at DESC;
Reproducibility
What makes benchmarks reproducible:
- Pinned environment: Docker image with pinned
torch==2.3.0+cu121,triton==2.3.0, driver version - Fixed hardware state: GPU boost clocks locked (
nvidia-smi -lgc 1410,1410for A100), no concurrent workloads - Fixed input: deterministic input generation (
torch.manual_seed(42)) - Warmup protocol: standardized warmup (20 iterations before measurement)
- Artifact storage: save
requirements.txt,Dockerfile, benchmark config, input tensors, and all raw timing measurements
Reproducibility score metric:
def reproducibility_score(run1_latencies, run2_latencies):
"""
CV = std / mean of latencies across two independent runs.
Score: 1 - max(CV_run1, CV_run2) — higher is more reproducible.
"""
cv1 = torch.tensor(run1_latencies).std() / torch.tensor(run1_latencies).mean()
cv2 = torch.tensor(run2_latencies).std() / torch.tensor(run2_latencies).mean()
return 1.0 - max(cv1.item(), cv2.item())
Target: reproducibility score > 0.98 (< 2% CV).
Regression Detection
def detect_performance_regression(new_result, baseline_result, threshold=0.05):
"""
Detect if new benchmark result is worse than baseline.
Uses p95 latency as primary metric (not p50 — tail matters for SLO).
threshold: relative degradation to flag (0.05 = 5%)
"""
p95_delta = (new_result["latency_p95_ms"] - baseline_result["latency_p95_ms"]) \
/ baseline_result["latency_p95_ms"]
throughput_delta = (new_result["throughput_req_s"] - baseline_result["throughput_req_s"]) \
/ baseline_result["throughput_req_s"]
regressions = []
if p95_delta > threshold:
regressions.append({
"metric": "p95_latency",
"delta": p95_delta,
"baseline": baseline_result["latency_p95_ms"],
"new": new_result["latency_p95_ms"],
})
if throughput_delta < -threshold:
regressions.append({
"metric": "throughput",
"delta": throughput_delta,
"baseline": baseline_result["throughput_req_s"],
"new": new_result["throughput_req_s"],
})
return regressions
CI/CD Integration
Fast benchmark (every PR, < 5 min)
# .github/workflows/benchmark_ci.yml
name: Performance Benchmark CI
on: [pull_request]
jobs:
benchmark:
runs-on: [self-hosted, gpu]
steps:
- uses: actions/checkout@v4
- name: Run fast benchmark
run: |
python benchmark_ci.py \
--model gpt2 \
--batch_sizes 1,8 \
--seq_lens 128,512 \
--n_iters 50 \
--compare_baseline \
--fail_threshold 0.1
Full benchmark (weekly)
name: Weekly Full Benchmark
on:
schedule:
- cron: "0 2 * * 1" # Monday 2 AM
jobs:
full_benchmark:
strategy:
matrix:
model: [gpt2, llama-7b, mistral-7b]
batch_size: [1, 8, 32]
seq_len: [512, 2048]
backend: [eager, inductor, tensorrt]
runs-on: [self-hosted, a100]
steps:
- name: Run benchmark
run: python run_benchmark.py --model ${{ matrix.model }} ...
- name: Upload results
run: python upload_results.py --db ${{ secrets.DB_URL }}
Dashboard Metrics
Key views:
- Latency over time: p50/p95/p99 per (model, hardware, backend) — detect regressions
- Throughput vs. batch size: shows batching efficiency curve
- Efficiency radar chart: 5 dimensions — throughput, memory, latency, accuracy, power
- Pareto frontier: accuracy vs. latency/throughput for all model variants
- Hardware comparison: same model across A100, H100, Snap 8 Gen 3
SLA definitions:
| Tier | Latency SLO | Use Case |
|---|---|---|
| Realtime | p99 < 100ms | Interactive chat, autocomplete |
| Batch | p99 < 1s | Async API, document processing |
| Edge | p99 < 50ms | On-device, offline |
| Throughput-optimized | > 1000 tok/s | Bulk inference |