« All Roles

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

CompanyRolePhases that map directly
QualcommSenior Staff ML Engineer — AI FrameworksAll phases; P06 is differentiating
AppleSr. Staff Engineer — Core ML AccuracyP01, P03, P04, P09, P10
NVIDIAPrincipal Engineer — TensorRT OptimizationP01, P04, P05, P07, P08
GoogleSr. Staff — JAX/XLA PerformanceP01, P04, P05, P07
MetaStaff Engineer — PyTorch CoreP01, P02, P04, P07
ArmSr. Staff — ML IP & CompilerP04, P05, P06, P07

What You Will Build

By the end of this curriculum you will have shipped and can demo:

  1. Custom autograd engine — implement reverse-mode autodiff from scratch in Python+C++, matching PyTorch semantics on standard test cases within 1e-8 numerical tolerance
  2. Custom C++ PyTorch operator — registered via TORCH_LIBRARY, with CPU+CUDA kernels, torch.compile-compatible, passing all OpCheck tests
  3. Transformer with full architectural variants — RoPE, ALiBi, GQA, MoE routing, all swappable — benchmarked on Wikitext-103
  4. Full PTQ → QAT → GPTQ → AWQ pipeline — automated, with accuracy-latency Pareto curves and regression gating
  5. torch.compile custom backend — FX graph lowering to a mock NPU ISA with >95% op coverage on LLaMA-style models
  6. TVM compilation pipeline — Relay → TIR → ARM/x86 compiled module with AutoTVM tuning, 2x+ speedup over baseline
  7. SNPE/QNN model deployment pipeline — ONNX → DLC → quantized NPU inference with full accuracy benchmarking vs FP32 baseline
  8. Roofline analysis toolkit — measure model arithmetic intensity, plot compute/memory ceilings, automatically identify bottleneck ops
  9. FlashAttention v2 from scratch in Triton — tiled SRAM-efficient implementation matching official FA2 output within 1e-5
  10. Speculative decoding engine — draft + verifier with acceptance rate profiling, 2–4x throughput improvement demonstrated
  11. Eval harness from scratch — MMLU, HellaSwag, GSM8K, perplexity; pluggable task registry, 3 sampling strategies, statistical significance reporting
  12. Accuracy regression CI — PR-gate pipeline that fails builds if accuracy drops >0.5% on key tasks after any optimization change
  13. On-device LLM end-to-end — LLaMA-3.2-1B → INT4 quantized → edge-benchmarked with accuracy delta vs FP16 documented
  14. 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

WeekPhaseFocus
1–201PyTorch dispatch stack, autograd engine from scratch
3–401C++ custom operator, TF tf.function tracing mechanics
5–602Transformer architectural variants (RoPE/ALiBi/GQA/MoE)
702ViT + DINO; multimodal CLIP→LLM bridge
8–903PTQ pipeline + QAT from scratch, calibration strategies
10–1103GPTQ, AWQ, SmoothQuant; accuracy-latency Pareto curves
1204TorchDynamo internals, FX graph transformation passes
1304Custom torch.compile backend + Triton fused kernels
1405ONNX export pipeline + optimization passes
1505TVM Relay→TIR pipeline + MLIR dialect intro
1606Qualcomm SNPE/QNN architecture + deployment pipeline
1706Hardware-aware graph partitioning + operator mapping
1807Roofline model theory + analysis tool from scratch
1907Custom profiling harness + bottleneck identification
2008FlashAttention v2 from scratch in Triton
2108Speculative decoding + continuous batching engine
2209Eval harness + accuracy regression pipeline
2310On-device LLM + LoRA accuracy recovery
2411Capstone integration projects + interview prep review

Each Lab Structure

FilePurpose
README.mdTheory, math derivations, design rationale, interview Q&A, talking points
lab.pyGuided exercise with # TODO markers — you fill in the blanks
solution.pyComplete reference solution with inline commentary
requirements.txtPinned pip dependencies
test_lab.pyAutomated 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

TierHardwareWhat runs
MinimumCPU only (8GB RAM)Phases 01–05, scaled-down labs
RecommendedSingle RTX 3090/4090 (24GB VRAM)All phases at full scale
IdealA100 80GB or H100Phase 08 FlashAttention, Phase 11 capstones
Edge simulationSnapdragon DevKit or AI Hub cloudPhase 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-written forward/backward; save_for_backward stashes 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.compile underdelivers.
  • 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_trace of modules gives call_module; aot/make_fx gives aten ops.
  • 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
dtyperangenotes
INT8 signed−128 … 127symmetric default
INT8 unsigned0 … 255asymmetric; don't cast to int8 before dequant — overflow!
INT4−8 … 7 (or 0 … 15)weight-only (GPTQ/AWQ); needs error comp / salient protection
NF416 normal-spaced levelsQLoRA 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

formatexp bitsmantissa bitsuse
FP32823baseline / accumulation
FP16510range-limited, precise-ish
BF1687FP32'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)

TechniqueWins onIdea
FlashAttentionlong seq, memory-boundtile + online softmax; never materialize N×N. Exact.
KV cacheautoregressive decodereuse past K/V; O(1)/token attention (memory grows w/ len)
Speculative decodinglatency, batch 1small draft proposes, big model verifies in parallel. Exact.
Continuous batchingserver throughputevict finished + admit new each step; keep HW saturated
Quantizationmemory-bound / fitmove 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)

capturenode ops you seeexample target
symbolic_trace(module)call_module"l1", "act"
Dynamo (default)call_function (torch fns)torch.relu, torch._C._nn.linear
make_fx / aotcall_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 via TORCH_LIBRARY / TORCH_LIBRARY_IMPL
  • Autograd engine: DAG construction during forward pass, AccumulateGrad, Engine::execute(), gradient accumulation, retain_graph, create_graph for higher-order gradients
  • torch.autograd.Function: custom forward/backward, save_for_backward, composite functions, double backward
  • Eager vs compiled mode: what torch.compile changes at the boundary — TorchDynamo captures bytecode into FX IR, TorchInductor lowers to Triton/C++
  • torch.fx symbolic tracing: Interpreter, Transformer, Graph, Node, proxy mechanism, limitations (control flow, data-dependent shapes)
  • Custom C++ operators: TORCH_LIBRARY schema, TORCH_LIBRARY_IMPL, pybind11 vs torch::Library, OpCheck, structured kernels
  • TF graph execution: Session.run() (TF1) vs tf.function + tf.Graph (TF2), ConcreteFunction, FuncGraph, AutoGraph transformation rules
  • tf.function tracing semantics: Python-side effects, tf.Variable vs 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 _decomp registry breaks complex ops into primitive ops for portability

Labs

Lab 01 — Custom Autograd Engine from Scratch

FieldValue
GoalImplement 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
ConceptsReverse-mode AD, topological sort, Function subclassing, Tensor wrapper, backward() dispatch
Steps1. 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
StackPython 3.10, NumPy 1.26, PyTorch (for verification only)
DatasetsSynthetic: random MLPs on MNIST features (loaded from sklearn)
Outputminigrad/ package; all 15 gradient checks pass within 1e-5 relative error
How to Testpython test_lab.py — runs gradcheck on all Function subclasses; trains a 2-layer MLP for 100 steps and validates loss decreases monotonically
Talking PointsWhy 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 BulletImplemented 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
ExtensionsAdd second-order gradient support; implement vmap style batched AD; add XLA-style HLO emission

Lab 02 — Custom C++ PyTorch Operator

FieldValue
GoalWrite 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
ConceptsTORCH_LIBRARY, TORCH_LIBRARY_IMPL, dispatch keys, at::Tensor, structured kernels, OpCheck, pybind11, CMake build system, torch.compile compatibility rules
Steps1. 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
StackC++17, CUDA 12.x, CMake 3.20+, PyTorch 2.3+ C++ extension API, torch.utils.cpp_extension
DatasetsSynthetic: torch.randn(batch, seq_len, vocab_size) tensors
Outputwarp_softmax.so loadable via torch.ops.load_library(); 10–20% latency improvement vs baseline on sequence length ≥ 4096
How to Testpytest 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 PointsWhat 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 BulletWrote 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
ExtensionsAdd 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

FieldValue
GoalBuild 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
Conceptstf.function, AutoGraph, ConcreteFunction, FuncGraph, tracing vs execution, Python side effects, input_signature, reduce_retracing, SavedModel, TFLite conversion
Steps1. 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
StackTensorFlow 2.15, TFLite 2.15, Python 3.10, tensorflow_datasets
DatasetsMNLI dev set (from tensorflow_datasets); BERT-tiny weights from HuggingFace
Outputgraph_inspector.py tool; FuncGraph visualization (JSON + text); MNLI accuracy: FP32 baseline vs TFLite dynamic-range within 0.5%
How to Testpython 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 PointsWhen 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 BulletBuilt TF2 graph analysis tool that identifies retracing triggers and validates TFLite conversion accuracy; demonstrated <0.3% MNLI accuracy degradation under dynamic-range quantization
ExtensionsAdd MLIR bridge analysis (tf.experimental.mlir_bridge); profile XLA compilation cost; instrument with TF Profiler

Deliverables Checklist

  • minigrad package with all 15 gradient checks passing
  • warp_softmax.so with OpCheck passing and benchmarks documented
  • TF graph inspector tool with FuncGraph visualization output
  • All test_lab.py suites pass: pytest phase-01-*/lab-0*/test_lab.py
  • Personal notes in each HITCHHIKERS-GUIDE.md section 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.compile use it?" (you can explain Meta kernels, Functionalize key, and why OpCheck tests all of them)
  • "When does tf.function retrace?" (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

Part II — How Computers Execute Code

Part III — PyTorch Architecture

Part IV — Graph Capture and Compilation

Part V — TensorFlow

Part VI — Numerical Precision

Part VII — Synthesis and Practice

References


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 fieldValueMeaning
shape(2, 3)2 rows, 3 columns
dtypefloat32each element is 4 bytes
strides(3, 1)to move 1 row: skip 3 elements; to move 1 col: skip 1 element
storage_offset0data starts at byte 0 of the storage
devicecpumemory 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 x is W
  • The VJP (upstream gradient g times Jacobian) is g @ W or equivalently W.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:

ApproachHow it worksAccuracySpeed
Numerical(f(x+h) - f(x-h)) / 2hApproximate (truncation error)O(n) passes for n inputs
SymbolicDerive the derivative expression algebraicallyExact but can "expression swell"Depends on expression complexity
AutomaticApply chain rule to each primitive op mechanicallyExact (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:

  1. Fetches the BINARY_ADD instruction
  2. Pops two PyObject* pointers from the stack
  3. Looks up the __add__ method on the left object
  4. Calls through multiple C function layers
  5. Allocates a new Python object for the result
  6. 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):

  1. CPython executes ONE bytecode instruction (CALL_FUNCTION)
  2. This calls a C++ function registered via pybind11
  3. The C++ function dispatches to a highly optimized BLAS routine (Intel MKL or cuBLAS)
  4. The kernel runs billions of multiplications and additions without returning to CPython
  5. 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:

  1. If arrays have different numbers of dimensions, the smaller is padded with 1s on the left
  2. Two dimensions are compatible if they are equal OR one of them is 1
  3. 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:

  1. Calls into C code
  2. Loops in C (100-200× faster than Python loops)
  3. May use SIMD to process 8 floats per CPU clock cycle
  4. 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 system
  • c10::Devicecpu:0, cuda:0, cuda:1, meta, etc.
  • c10::ScalarTypefloat32, 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's torch.Tensor wraps)
  • ~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:

  1. Python's torch.relu is a Python wrapper generated from the ATen operator registry
  2. It calls at::relu(x) in C++
  3. at::relu is registered with the dispatcher under the key aten::relu
  4. 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/else in 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 of Edge objects pointing to upstream nodes
  • input_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 upstream Node
  • input_nr: which input of that Node this 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_: the Node that produced this tensor (null for leaves)
  • grad_: accumulated gradient (null until backward is called)
  • requires_grad_: whether this tensor needs gradients
  • output_nr_: which output of grad_fn_ this tensor is

11.2 DAG Construction During Forward Pass

When the Autograd dispatch key intercepts an operation, it:

  1. Calls the compute kernel (CPU/CUDA) to get the output values
  2. Creates a new Node subclass specific to this operation (e.g., MmBackward0 for matmul)
  3. Saves any tensors the backward will need (via save_for_backward)
  4. Sets next_edges_ to point to the grad_fn_ of each input (or AccumulateGrad for leaves)
  5. Attaches this new Node as the grad_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:

  1. Creates a priority queue (or thread pool) of nodes to process
  2. Initializes the root node (c.grad_fn = MmBackward0) with grad = ones_like(c)
  3. 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
  4. When AccumulateGrad runs, adds incoming gradient to leaf.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 Node objects (no MmBackward0, etc.)
  • No AutogradMeta on 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):

  1. PyTorch's memory-efficient gradient checkpointing cannot track this tensor
  2. The version counter check (detecting in-place mutations) is bypassed — you may compute wrong gradients silently if x is modified in-place after the forward
  3. 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 (unless retain_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 Proxy representing 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) → records call_module[linear]
  • torch.relu(intermediate_proxy) → records call_function[relu]
  • return proxy.sum() → records call_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 of placeholder, call_function, call_module, call_method, output
  • node.name: a unique string identifier
  • node.target: what is being called (a function, module name, or method name)
  • node.args: positional arguments (may reference other nodes)
  • node.kwargs: keyword arguments
  • node.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:

  1. Python calls relu(x) → dispatch → CUDA kernel launch
  2. Python calls linear(x) → dispatch → cuBLAS call
  3. 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 + linear into 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:

  1. Runs the forward with FakeTensors (no data, just shapes/dtypes)
  2. Runs autograd on the traced forward to symbolically derive the backward
  3. Produces a joint FX graph containing both forward and backward as one graph
  4. 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:

  1. Executes x = x * 2.0f with even threads active, odd threads masked off
  2. Executes x = x + 1.0f with 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

MemoryLocationSize (A100)LatencyBandwidth
RegistersPer-thread (on SM)256 KB/SM0 cycles~20 TB/s
Shared MemoryPer-block (on SM)48-192 KB/SM~5 cycles~12 TB/s
L1/Texture CachePer-SM192 KB/SM~20 cycles~8 TB/s
L2 CacheGPU-wide40 MB~200 cycles~2 TB/s
HBM2e (Global Mem)Off-chip DRAM80 GB~800 cycles2 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:

  1. CPU/CUDA correctness: output matches reference (F.softmax) within 1e-5
  2. Autograd correctness: torch.autograd.gradcheck on differentiable inputs
  3. Meta kernel: shape propagation with device='meta' tensors
  4. FakeTensor mode: compatibility with torch.compile's shape tracking
  5. 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:

PythonAutoGraph 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:

  • tf dialect: TF-level operations
  • mhlo dialect: the HLO equivalent in MLIR
  • linalg dialect: linear algebra operations with explicit loop structure
  • memref dialect: 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?

  1. Sign: negative → bit 31 = 1
  2. 6.5 in binary: 110.1 = 1.101 × 2^2
  3. Exponent: 2 + 127 = 129 = 10000001 in binary
  4. Mantissa: 101 followed 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 bitsMantissa bitsMeaning
0000000000000...000±0
00000000nonzeroDenormal number (very small, near 0)
1111111100000...000±Infinity
11111111nonzeroNaN (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; individual exp(x_i) may be small relative to the sum
  • Layer normalization: subtracting the mean from large-valued activations
  • Log probabilities: log(p) where p is 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.backward for the VJP formula
  • When a gradient is None: you know to check requires_grad, no_grad() scope, or detach()
  • When a model doesn't converge: you know to run gradcheck to 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_LIBRARY registration pattern is identical for CPU, CUDA, and NPU backends
  • The Meta kernel 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.
  • opcheck is 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:

  1. csrc/warp_softmax.cpp: understand TORCH_LIBRARY (schema), TORCH_LIBRARY_IMPL (CPU + Meta), warp_softmax_cpu (ATen ops for CPU), warp_softmax_meta (returns empty_like).

  2. csrc/warp_softmax.cu: read warp_reduce_max and warp_reduce_sum first — these are the butterfly reduction utilities. Then read warp_softmax_kernel — trace through the three steps (find max, compute exp+sum, normalize). Map each line to the hardware concepts from Chapter 15.

  3. setup.py: understand how torch.utils.cpp_extension.CUDAExtension compiles your C++/CUDA and links against libtorch.

  4. solution.py: understand how opcheck is 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:

  1. trace_and_inspect first — until you can see a FuncGraph's ops, captures, and signature, the rest is blind. Key API: fn.get_concrete_function(*inputs).graph.
  2. 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).
  3. demonstrate_retracing — predict the three trace counts on paper before running; if your prediction misses, re-read §18's retracing rules.
  4. stabilizetf.TensorSpec([None, ...]) makes shape symbolic; verify one trace serves all batch sizes.
  5. 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:

  1. Trace a 5-op PyTorch expression's full backward pass with specific gradient values at each node.
  2. Name every dispatch key in priority order and explain what each intercepts.
  3. Give 3 distinct triggers for tf.function retracing and the prevention for each.
  4. Explain what breaks in torch.compile if your custom op has no Meta kernel.
  5. Draw the butterfly reduction for warp_reduce_max with 8 threads (3 rounds).
  6. Explain why BF16 doesn't need loss scaling but FP16 does.
  7. 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. JAX: Bradbury, J. et al. (2018). JAX: Composable Transformations of Python+NumPy Programs. — JAX's jit, grad, vmap show an alternative design to PyTorch's; understanding both gives perspective on framework design tradeoffs.

  7. MLIR: Lattner, C. et al. (2021). MLIR: Scaling Compiler Infrastructure for Domain Specific Computation. CGO 2021. — The paper behind TF's MLIR compilation infrastructure.

  8. XLA: Leary, C. et al. (2017). XLA: TensorFlow, Compiled. TF Dev Summit 2017. — XLA's design and its HLO IR.

Books

  1. 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.

  2. 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.

  3. 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.

  4. 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

  1. 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.

  2. 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.

  3. AOTAutograd Walkthroughhttps://pytorch.org/functorch/stable/notebooks/aot_autograd_optimizations.html — Official documentation with worked examples.

  4. 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.

  5. torch.fx Documentationhttps://pytorch.org/docs/stable/fx.html — Official API docs with examples of graph transformation patterns.

  6. tf.function Guidehttps://www.tensorflow.org/guide/function — TF's official guide covering tracing, retracing, and AutoGraph in depth.

  7. Mixed Precision Training — Micikevicius, P. et al. (2018). Mixed Precision Training. ICLR 2018. — The paper that introduced loss scaling for FP16 training.

  8. The TORCH_LIBRARY Tutorialhttps://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:

  1. The saved tensors needed for the backward pass
  2. References to the next_functions (upstream nodes in the DAG)

Backward pass — Engine::execute():

The backward pass is triggered by d.backward(). The engine:

  1. Starts from d.grad_fn with initial gradient torch.ones(1)
  2. Calls AccumulateGrad for leaf tensors
  3. Calls each grad_fn.__call__(gradient_from_downstream) in reverse topological order
  4. 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:

  1. ctx.save_for_backward(x) → storing x directly in ctx (leaks memory, misses memory-efficient inplace handling)
  2. Returning wrong number of gradients — must match number of forward inputs exactly (use None for non-differentiable)
  3. 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

  1. No data-dependent control flow: if x.sum() > 0: cannot be traced — the Proxy doesn't know the value
  2. No dynamic shapes by default: shapes are captured at trace time and become constants
  3. Closures and globals: Python closures are NOT captured in the graph — they become constants at trace time, which is a semantic footgun
  4. torch.compile vs FX: torch.compile uses TorchDynamo (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:

  1. Disassembles the bytecode with dis.get_instructions()
  2. Tracks every operation that produces a tensor
  3. When it hits an unsupported operation (Python control flow on tensor values), it breaks the graph and falls back to Python
  4. 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:

  1. Tracing: Python executes with SymbolicTensor objects (like PyTorch Proxies)
  2. AutoGraph transformation: Python if/for/while are converted to tf.cond/tf.while_loop
  3. Graph construction: a FuncGraph is built representing the TF ops
  4. Compilation: the graph is compiled to a ConcreteFunction with a specific input signature
  5. 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

FormatSignExponentMantissaRangePrecision
FP321823±3.4e38~7 decimal digits
FP161510±65504~3.3 decimal digits
BF16187±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), then 1e8 - 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

QuestionWrong AnswerCorrect 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

👨🏻 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.md before 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:

  • Value class wrapping scalars with +, *, tanh, relu, exp, pow operations
  • Reverse-mode automatic differentiation via .backward()
  • Dynamic computation graph construction (define-by-run)
  • Gradient accumulation across shared nodes (topological sort)

Key concepts

ConceptWhat to understand
Computation graphEach op creates a node; edges are _prev set
Chain ruledL/dx = dL/dout * dout/dx applied backwards
STEHow gradients skip through non-differentiable ops
detach()Stops gradient flow — critical for quantization

Files

FilePurpose
lab.pyStub with TODO markers — implement this
solution.pyComplete working implementation
test_lab.pypytest suite — run to verify your work
requirements.txtDependencies

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.md before starting.

What you build

A custom PyTorch operator written in C++ that registers with the dispatcher:

  • C++ extension using pybind11 and torch/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

ConceptWhat to understand
ATen dispatcherHow torch.ops.mylib.op() routes to C++
at::TensorC++ tensor API — same data, different language
setup.py + torch.utils.cpp_extensionBuild system for extensions
SchemaThe type signature registered with the dispatcher

Files

FilePurpose
setup.pyBuild configuration for C++ extension
csrc/C++ source files
solution.pyPython tests and usage
requirements.txtPython 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.function is 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 the ConcreteFunction for given inputs and produce a GraphReport: node counts by op type, captured external tensors, structured input signature
  • RetraceProbe — a wrapper that counts how many times Python tracing actually runs, used to demonstrate (and then prevent) the classic retracing triggers
  • stabilize — apply an explicit input_signature so shape/value changes reuse one trace
  • autograph_diff — show the AutoGraph-transformed source of a Python function with if/for control flow (what your Python actually becomes)
  • roundtrip_savedmodel — save → reload → verify outputs match exactly
  • convert_tflite_dynamic — TFLite dynamic-range quantization with max-abs output delta vs the TF model on random probe inputs

Key concepts

ConceptWhat to understand
Trace vs executePython body runs ONCE per signature (trace time); the graph runs every call
Retracing triggersNew dtype, new rank/shape (without signature), new Python value, new closure object
Python side effectsprint(), list appends, counters fire at trace time only — a top-3 production bug source
input_signaturePins the trace to symbolic shapes — None dims accept any size with zero retraces
ConcreteFunctionA traced graph + signature; fn.get_concrete_function(...) exposes the FuncGraph
AutoGraphRewrites Python if/while into tf.cond/tf.while_loop when operands are tensors
TFLite dynamic-rangeWeights stored INT8, activations computed FP32 — cheapest quantization, first accuracy checkpoint

Files

FilePurpose
lab.pySkeleton with # TODO markers — implement each utility
solution.pyComplete reference implementation with commentary
test_lab.pyPytest suite validating retrace counts, graph reports, SavedModel/TFLite round-trips
requirements.txttensorflow>=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

  1. trace_and_inspect — everything else builds on being able to see a FuncGraph
  2. RetraceProbe — prove to yourself exactly which calls retrace and why
  3. stabilize — make the retraces stop with an input_signature
  4. autograph_diff — look at what AutoGraph did to your if statement
  5. roundtrip_savedmodel, then convert_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() inside tf.function fires once but tf.print fires 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) and fn.experimental_get_compiler_ir(...)('hlo')
  • Profile trace time vs execution time with the TF Profiler

References

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

FieldValue
GoalImplement 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
ConceptsGQA/MQA/MHA, RoPE, ALiBi, SwiGLU, RMSNorm, pre-norm, KV cache, causal masking
Steps1. 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
StackPyTorch 2.3+, datasets (HuggingFace), tiktoken
DatasetsWikitext-103 (datasets.load_dataset("wikitext", "wikitext-103-v1"))
OutputModel configurations A–E with perplexity table; KV cache correctness test (cached vs non-cached outputs match); GFlops comparison across configs
How to Testpython 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 PointsWhy 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 BulletImplemented 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
ExtensionsAdd sliding window attention (Mistral); implement expert routing for MoE; add Flash-attention-compatible causal mask

Lab 02 — Mixture of Experts (MoE)

FieldValue
GoalImplement the MoE layer used in Mixtral and Switch Transformer: top-K routing, auxiliary load-balancing loss, and expert-utilization diagnostics
ConceptsConditional compute, TopKRouter, Switch Transformer aux loss $n_{experts}\sum_i f_i p_i$, expert collapse, top-1 vs top-2 routing
Steps1. 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
StackPyTorch 2.3+
OutputWorking MoE block with passing tests; expert-utilization report showing balanced routing under aux loss
How to Testpytest test_lab.py — validates routing shapes, aux loss formula, top-K combination weights summing to 1, utilization balance
Talking PointsWhy 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 BulletImplemented Mixtral-style MoE layer with Switch Transformer load balancing from scratch; diagnosed and prevented expert collapse via auxiliary loss and utilization monitoring
ExtensionsAdd 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)

FieldValue
GoalImplement the SSM recurrence, ZOH discretization, and Mamba's selective scan; demonstrate O(N) memory vs O(N²) attention
ConceptsSSM 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
Steps1. 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
StackPyTorch 2.3+
OutputWorking Mamba block with passing tests; memory comparison table attention vs SSM across sequence lengths
How to Testpytest test_lab.py — validates ZOH math, recurrence outputs, selective parameter shapes, memory scaling claim
Talking PointsWhy 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 BulletImplemented Mamba selective state space model from scratch including ZOH discretization and selective scan; demonstrated linear-memory long-context inference vs quadratic attention
ExtensionsImplement 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.

FieldValue
GoalImplement ViT from scratch and replicate the DINO self-supervised training loop (student-teacher with centering + sharpening); validate that attention maps show semantic segmentation behavior
ConceptsPatch embedding, positional embedding, class token, multi-head self-attention, DINO momentum teacher, InfoNCE/cross-entropy with temperature, centering, attention map visualization
Steps1. 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
StackPyTorch 2.3+, torchvision, matplotlib
DatasetsCIFAR-10 (via torchvision); optional: STL-10 for better quality
OutputTrained ViT-Tiny/CIFAR10; attention map visualizations showing semantic segmentation; linear probe accuracy ≥80% on CIFAR-10 without labels
How to Testpython 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 PointsWhy 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 BulletImplemented 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
ExtensionsReplace 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.

FieldValue
GoalBuild 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
ConceptsCLIP ViT-L/14 feature extraction, projection MLP, visual token prepending, instruction fine-tuning format, frozen vs unfrozen components, multimodal loss masking
Steps1. 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
StackPyTorch 2.3+, transformers, datasets, Pillow
DatasetsLLaVA-Instruct-150K (image subset, ~50k samples); VQAv2 val (for evaluation)
OutputTrained projector weights; VQAv2 validation accuracy (target: >50%); qualitative demos of image Q&A
How to Testpython 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 PointsWhy 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 BulletBuilt 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)
ExtensionsAdd 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.py suites pass

Guides in This Phase

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

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?

VariantKV headsKV cache vs MHAQuality
MHA$h$ (one per query head)best
GQA$g$ groups ($1 < g < h$)$g/h$ (e.g. ⅛ for LLaMA-3)≈MHA with modest training
MQA1$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_zoh is 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 writing loss().

Success Criteria

You are ready for Phase 03 when you can, from memory:

  1. Compute the KV cache size of any (layers, kv_heads, head_dim, context, dtype) config and state the GQA reduction factor.
  2. Derive why $\sqrt{d_k}$ scaling exists from the variance of random dot products.
  3. Explain RoPE's relative-position property (rotations compose; dot product depends on $m-n$) without notes.
  4. State both MoE collapse modes and how $\sum f_i p_i$ fixes them.
  5. Write the ZOH discretization from the continuous SSM and explain what selectivity changes computationally (convolution trick breaks → scan).
  6. Explain DINO's two anti-collapse mechanisms and what happens if either is removed.
  7. 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

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:

  1. The DINO loss forces the CLS token to represent the "semantic essence" of the full image
  2. Background patches carry less semantic information and receive lower attention weight
  3. 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:

  1. Sparse activation: different tokens activate different experts → no batching across tokens for expert computation
  2. Expert parameter loading: each expert's weights must be available — 8 experts × 7B params = 56B total (even though only 14B active per token)
  3. 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

PropertyTransformer (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)$ parallelismParallel scan ($O(n \log n)$ or $O(n)$ with associativity)
NPU friendlinessHigh (large matmuls)Medium (recurrent ops, smaller matmuls)
Long-sequence accuracyDegrades without positional bias tuningStrong by design

5. Key Interview Numbers

ArchitectureActive Params per TokenKV Cache per Layer (FP16, n=4096)Notes
LLaMA-3-8B8B4 MB (GQA, g=8)h=32, g=8, d=128
LLaMA-3-70B70B16 MB (GQA, g=8)h=64, g=8, d=128
Mixtral 8x7B~13B (top-2 routing)8 MB8 experts, 2 active
ViT-L/14307MN/A (no KV cache at inference)196 patches
Mamba-3B3B1 MB flat stateNo 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 rationalewhy 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

ModelExpertsActive per tokend_modelNotes
Switch Transformer128–20481 (top-1)1024–4096Google, top-1 for simplicity
GLaM64281921.2T params, 97B active
Mixtral 8×7B824096Most popular open-source MoE
GPT-4 (rumored)162~12288Dense+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:

  1. Diagonal-plus-low-rank (DPLR) parameterization of A — can be efficiently computed
  2. HIPPO initialization: A initialized to approximate memorizing the input using Legendre polynomial projections
  3. 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:

PropertyAttentionMamba
MemoryO(T²)O(T)
ComputeO(T²)O(T)
Content-selectivity✅ (QK dot product)✅ (selective B, C, Δ)
Parallelism in training✅ (parallel scan)
Inference per stepO(T) KV cacheO(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 TAttention (T²) elementsSSM (T × d_state, d_state=16)Ratio
644,0961,024
1,0241,048,57616,38464×
8,19267,108,864131,072512×
32,7681,073,741,824524,2882048×

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.md before 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

ConceptWhat to understand
KV cacheMQA/GQA reduce memory for long context inference
Head groupingG groups share KV, reducing VRAM at minimal accuracy cost
RoPERotates Q and K before dot product — relative positions for free
Flash-attention patternWhy attention needs tiling for memory efficiency

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyFull working implementation + smoke test
test_lab.pypytest suite
requirements.txtDependencies

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

ConceptWhat to understand
Conditional computeEach token uses only K of N experts (sparse activation)
Aux lossn_experts * Σ(f_i * p_i) — penalizes load imbalance
Expert collapseWhen all tokens route to same expert — training failure mode
Top-1 vs Top-2Switch Transformer uses top-1; Mixtral/GPT-4 uses top-2

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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·B
  • s4_recurrence — Sequential SSM scan over a sequence
  • SelectiveSSM — Mamba's input-dependent B, C, Δ (selection mechanism)
  • MambaBlock — full block: in_proj + depthwise conv + SelectiveSSM + gating
  • compare_memory_usage — O(T²) attention vs O(T·d_state) SSM

Key concepts

ConceptWhat to understand
SSM state equationh_t = Ā·h_{t-1} + B̄·x_t, y_t = C·h_t + D·x_t
ZOH discretizationConverts continuous-time A, B to discrete Ā, B̄
Selective scanB, C, Δ are functions of x — allows content-based memory
Linear recurrenceO(N) sequential scan vs O(N²) attention matrix
Hardware-efficientMamba uses parallel scan + recomputation for GPU efficiency

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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, plus get_last_attention for the famous segmentation-without-labels visualization
  • DINOHead — 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 collapse
  • momentum_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

ConceptWhat to understand
Collapse modesUniform output (fixed by sharpening, τ_t < τ_s) and one-hot output (fixed by centering) — each fix alone admits the other collapse
Momentum teacherNever trained by gradients; EMA of student weights is a smoother, better target than the student itself
Multi-cropLocal crop → global view correspondence is the actual supervisory signal
Stop-gradientTeacher outputs are detached; gradients flow only through the student branch
Emergent segmentationCLS-token attention rows of the last block localize objects with zero segmentation labels

Files

FilePurpose
lab.pySkeleton with # TODO markers
solution.pyComplete reference with commentary; python solution.py runs a synthetic demo
test_lab.py13 tests: patch non-overlap, attention rows, no-grad-to-teacher, centering math, EMA math, loss decrease
requirements.txttorch, 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

  1. PatchEmbed — get the conv-as-projection reshape right first
  2. ViT.forward — CLS prepend, pos embed, blocks, norm, return position 0
  3. DINOHead — note the L2 normalize before the prototype layer
  4. DINOLoss — the detach and the center update are where the learning is
  5. momentum_update + the DINO wrapper (teacher frozen!)
  6. 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.py style 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

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 sequence
  • loss — 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

ConceptWhat to understand
Patch tokens, not CLSLLaVA feeds all patch tokens to the LM — VQA needs spatial detail the pooled embedding discards
Freezing disciplinerequires_grad_(False) + vision under no_grad — optimizer never sees frozen params, no grad buffers allocated
The causal shiftLogits at the LAST visual position predict the FIRST text token; off-by-one here is the classic multimodal bug (a test targets it)
Loss maskingPrompt/question positions set to -100 — grade the answer, not the echo
Stage-1 vs stage-2LLaVA trains projector-only on captions first, then unfreezes the LM for instruction tuning

Files

FilePurpose
lab.pySkeleton with # TODO markers
solution.pyComplete reference; python solution.py shows projector-only training working
test_lab.py12 tests: freezing, grad flow, logit alignment, masking, image→text information flow
requirements.txttorch, pytest

Run

pip install -r requirements.txt
pytest test_lab.py -v
LAB_MODULE=solution pytest test_lab.py -v

Suggested TODO order

  1. Projector — two lines of architecture, but know why MLP > single linear
  2. MultimodalModel.__init__ — the freezing; check test_only_projector_is_trainable
  3. visual_tokensforward — concatenation order matters
  4. loss — draw the position layout on paper before coding the slice
  5. generate

Success criteria

  • All 12 tests pass — especially test_loss_alignment_uses_image_conditioned_prediction (the off-by-one catcher) and test_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

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

FieldValue
GoalBuild 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
ConceptsMin/max calibration, percentile calibration, per-tensor vs per-channel, quantize/dequantize (QDQ) nodes, accuracy regression
Steps1. 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
StackPyTorch 2.3+, transformers, datasets, lm_eval
DatasetsWikiText-2, MMLU (via lm_eval)
OutputAccuracy comparison table: FP16 vs INT8 per-tensor vs INT8 per-channel vs INT4 per-channel across 3 calibration strategies; calibrated .pt model
How to Testpytest 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 PointsWhy is per-channel better than per-tensor? What is the KL-divergence calibration optimizing? When would you use percentile over min-max?
Resume BulletBuilt 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
ExtensionsAdd rotation-based outlier suppression (QuaRot); implement per-group quantization (group_size=128); add accuracy CI comparison with statistical significance

Lab 02 — QAT from Scratch

FieldValue
GoalImplement 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
ConceptsFake quantization, STE, torch.quantization.FakeQuantize, ObserverBase, HistogramObserver, QAT fine-tuning schedule, fold-BN-into-conv before deployment
Steps1. 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
StackPyTorch 2.3+, torchvision
DatasetsCIFAR-10
OutputTrained QAT model; accuracy table FP32 vs PTQ vs QAT (INT8 weights + INT8 activations); size comparison
How to Testpytest 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 PointsWhy 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 BulletImplemented 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
ExtensionsImplement 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.

FieldValue
GoalImplement 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
ConceptsHessian-based optimal quantization (GPTQ), activation magnitude analysis (AWQ), scale migration (SmoothQuant), group quantization, accuracy vs throughput Pareto curve
Steps1. 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)
StackPyTorch 2.3+, transformers, lm_eval, matplotlib
DatasetsWikiText-2, MMLU, C4 (for calibration)
Output3-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 Testpytest 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 PointsWhy 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 BulletImplemented 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
ExtensionsAdd 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.py suites pass
  • Pareto curve plots saved as PNG artifacts

Guides in This Phase

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

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:

FormatBitsExponentMantissaRangePrecision
FP3232823±3.4e38~7 decimal digits
FP1616510±65504~3 digits — overflows easily
BF161687FP32's range~2 digits — never overflows where FP32 doesn't
INT88[-128, 127]uniform steps
INT44[-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:

  1. Rounding error: uniform in $[-s/2, s/2]$, variance $s^2/12$. Shrinks as range shrinks.
  2. 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:

  1. Wrap target layers (QuantizedLinear replacing nn.Linear).
  2. Attach observers; run calibration batches; collect ranges.
  3. Compute $(s, z)$ per quantizer from the chosen calibration rule.
  4. Quantize weights offline; activations quantize at runtime (or simulate with QDQ — quantize-dequantize — nodes for accuracy evaluation in float).
  5. 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:

  1. Quantize columns in a fixed order for all rows (vectorizes across the matrix).
  2. Maintain $H^{-1}$ updates via Cholesky factorization (numerically stable; the naive rank-1 inverse updates accumulate error and produce garbage at 175B scale).
  3. 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/dequantize and 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.Function first and gradcheck your 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_hessian and 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, then compare_reconstruction_error — GPTQ must beat round-to-nearest clearly.

Success Criteria

You are ready for Phase 04 when you can, from memory:

  1. Write the affine quantization map and derive $s$ from a range and bit-width; state the rounding-vs-clipping tension in one sentence.
  2. Explain why weights are symmetric-per-channel and activations asymmetric-per-tensor in standard deployments.
  3. State the STE, why it's wrong, and why it works anyway.
  4. Write GPTQ's compensation update and explain each factor ($[H^{-1}]_{qq}$ in the denominator = move most where curvature is flattest).
  5. Explain the activation-outlier phenomenon and the SmoothQuant rescaling identity.
  6. 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

MethodLLaMA-7B WikiText-2 PPLMMLU 5-shotNotes
FP16 baseline5.6867.9%Reference
INT8 per-tensor6.1262.1%Naive PTQ
INT8 per-channel (KL calib)5.7167.4%Good PTQ
INT4 PTQ naive8.9448.3%Degraded
INT4 GPTQ (g=128)6.0966.8%Near FP16
INT4 AWQ (g=128)6.2466.2%Simpler
INT4 QAT5.9867.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:

FormatBitsSize reductionMemory (7B params)Typical accuracy loss
FP323228 GBReference
FP16/BF161614 GB<0.1%
INT887 GB0.1–0.5%
INT443.5 GB0.5–2% (with good algo)
INT4 (naive)43.5 GB5–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:

  1. Weight smoothing: weights tend toward values that quantize well (multiples of the scale)
  2. Reduced sensitivity: activations become less sensitive to weight perturbations (the model learns to be more robust)
  3. 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

ScenarioUse PTQUse QAT
INT8 target, any modelOverkill
INT4 target, BERT/RoBERTaMaybe
INT4 target, LLM (7B+)Only GPTQ✅ if you have 7B training budget
Tight latency + accuracyMeasure 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$):

  1. Quantize column $j$: $w_q^{(j)} = \text{round}(W[:, j] / s) \times s$
  2. Compute quantization error: $\delta^{(j)} = W[:, j] - w_q^{(j)}$
  3. 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:

  1. Process columns left-to-right (no selection overhead)
  2. 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:

  1. Numerical stability: Cholesky only works for positive-definite matrices (which $H$ is, after damping), and the triangular solve is well-conditioned
  2. Efficiency: compute $H = LL^T$ once ($O(n^3)$), then triangular solves are $O(n^2)$
  3. 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:

MethodPPL (lower = better)VRAMSpeed
FP16 baseline5.4714 GB
Naive INT4~30+3.5 GB2.5×
GPTQ INT45.853.5 GB2.5×
AWQ INT45.783.5 GB2.5×
QAT INT45.653.5 GB2.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

ConceptWhat to understand
Scale & zero-pointMapping float range to integer range
Per-channel quantEach output channel has own scale — better for weight quantization
Calibration dataRepresentative inputs to measure activation ranges
Accuracy-vs-compression tradeoffINT4 = 8× compression, INT8 = 4× compression

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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 weights
  • wrap_model_for_qat — replace all Linear layers with QATLinear (deepcopy safe)
  • train_qat — AdamW training loop on QAT model
  • convert_to_int8 — fold scale/zero_point into INT8 weights post-training

Key concepts

ConceptWhat to understand
STEx_out = x + (quant(x) - x).detach() — gradients flow through
FakeQuantizeQuantize + dequantize in forward; model learns to minimize error
INT8 conversionAfter QAT, actual INT8 weights match dequantized FakeQuant output
Accuracy vs PTQQAT typically +1–3% accuracy over PTQ at same bit width

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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 activations
  • symmetric_quantize — INT4/INT8 symmetric per-tensor quantization
  • gptq_quantize_layer — column-wise OBQ with Cholesky-based Hessian inverse update
  • compare_reconstruction_error — GPTQ vs naive round-to-nearest
  • GPTQLayer — inference layer with W_q as buffer (no grad)
  • gptq_quantize_model — full pipeline with forward hooks for calibration

Key concepts

ConceptWhat to understand
OBQOptimal Brain Quantizer: minimize per-column reconstruction error
HessianH = 2XX^T captures sensitivity of loss to weight perturbations
Cholesky updateNumerically stable Hessian inverse for sequential column updates
Error propagationQuantizing column j updates remaining columns to compensate
GPTQ vs AWQGPTQ: Hessian-based; AWQ: activation scaling — both INT4 SOTA

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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 Node objects 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 via torch.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.compile specializes on input shapes by default (fast) or can use dynamic=True for shape-agnostic compilation (slower, more general)
  • Custom backends: any callable (fx.GraphModule, example_inputs) → callable can be registered as a torch.compile backend
  • Triton: Python-embedded GPU kernel language; used by TorchInductor; enables writing performance-critical kernels without CUDA C++

Labs

Lab 01 — FX Graph Transformation Passes

FieldValue
GoalWrite 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)
Conceptstorch.fx.symbolic_trace, torch.fx.Interpreter, torch.fx.Transformer, node pattern matching, torch.nn.utils.fusion.fuse_conv_bn_eval, GraphModule.recompile()
Steps1. 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)
StackPyTorch 2.3+, torchvision
DatasetsImageNet val subset (1000 images)
OutputPassManager with all 4 passes; ResNet-50 benchmark: latency reduction with <0.1% accuracy drop; pass unit tests
How to Testpytest 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 PointsWhat is the mathematical equivalence behind conv+BN fusion? Why can constant folding improve latency? What makes DCE safe (when might DCE be incorrect)?
Resume BulletWrote 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
ExtensionsAdd 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

FieldValue
GoalWrite 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
Steps1. 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
StackPyTorch 2.3+, torch._dynamo, torch.fx
DatasetsSynthetic (random inputs)
OutputRegistered npu_simulator backend; LLaMA-tiny running under torch.compile(backend="npu_simulator") with >85% ops handled by NPU; op coverage report
How to Testpytest 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 PointsHow 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 BulletWrote custom torch.compile backend targeting mock NPU ISA; achieved 87% op coverage for LLaMA-style models with automatic CPU fallback for unsupported ops
ExtensionsAdd 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

FieldValue
GoalWrite 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
ConceptsTriton programming model, blocked tile computation, SRAM vs DRAM, Inductor lowering via @torch._inductor.lowering.register_lowering, FX pattern matching, performance benchmarking
Steps1. 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
StackTriton 2.3+, PyTorch 2.3+, CUDA GPU required
DatasetsSynthetic: torch.randn(batch, seq, d_model)
Outputlayernorm_gelu_kernel achieving ~1.4× speedup over sequential on batch=32,seq=2048,d=4096; registered as Inductor lowering with correctness verification
How to Testpytest 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 PointsHow 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 BulletWrote fused LayerNorm+GeLU Triton kernel registered as TorchInductor lowering; achieved 1.43× speedup over sequential ops at transformer-scale dimensions (seq=2048, d=4096)
ExtensionsAdd 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.compile backend with op coverage report
  • Triton fused kernel registered as Inductor lowering
  • All test_lab.py suites 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.compile backend 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

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:

  1. 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.
  2. 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.
  3. 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 of placeholder (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 other Nodes — this linking is the dataflow graph.
  • meta: a dict where shape/dtype propagation and your own passes stash information (node.meta["tensor_meta"] after ShapeProp).

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 a supported_ops set 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:

  1. Name the six FX node ops and walk a printed graph aloud.
  2. Write conv-BN folding's weight/bias formulas and state the single-user precondition.
  3. Explain how Dynamo captures (bytecode symbolic execution), what a guard is, and the recompile-vs-graph-break distinction.
  4. List four graph-break triggers and the tensor-level rewrite for each.
  5. Describe what AOTAutograd and decompositions transform, and why backends want core ATen IR rather than all ~2000 ops.
  6. 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

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:

  1. A Triton/C++ dispatch overhead
  2. Loss of fusion opportunities across the break
  3. 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

NodeKindExampleDescription
placeholderx: f32[B,512]Function argument
get_attrself.weightModule parameter access
call_functiontorch.relu(x)Free function call
call_methodx.sum()Tensor method call
call_moduleself.linear(x)nn.Module forward call
outputreturn yFunction 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 handles
  • tl.load/tl.store: vectorized memory ops with optional masking
  • tl.dot: matrix multiply of tiles (uses tensor cores)
  • tl.constexpr: compile-time constant (like CUDA constexpr template 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):

PassWhat It DoesWhen Useful
Dead Code Elimination (DCE)Remove nodes whose outputs are never usedAfter any graph modification
Common Subexpression Elimination (CSE)Share identical computationsRepeated ops in transformers
Constant FoldingEvaluate constant subgraphs at compile timeBias initialization, shape computations
Operator FusionCombine adjacent ops into one kernelMemory-bound op sequences
Layout PropagationPropagate preferred memory layouts (NHWC vs NCHW)Conv-heavy models
Quantization InsertionInsert Q/DQ nodes around quantizable opsINT8/INT4 deployment
Shape PropagationInfer output shapes for all nodesEnables downstream static allocation
Alias AnalysisTrack tensor aliasing for in-place op safetyBefore in-place mutations

Section 6: Interview Pitfalls

QuestionWrong AnswerRight 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

  1. PyTorch torch.compile tutorial — https://pytorch.org/tutorials/intermediate/torch_compile_tutorial.html
  2. TorchDynamo overview — Jason Ansel et al., "TorchDynamo: Towards Principled Python JIT Compilation" (Meta AI, 2022)
  3. 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
  4. Triton paper — Philippe Tillet et al., "Triton: An Intermediate Language and Compiler for Tiled Neural Network Computations" (MLSys 2019)
  5. FX documentation — https://pytorch.org/docs/stable/fx.html
  6. torch.export documentation — https://pytorch.org/docs/stable/export.html
  7. 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.compile transforms your model before lowering to a backend.

What you build

  • symbolic_trace a model to capture its FX graph
  • Write a graph pass that fuses linear → relu into a single node
  • Write a graph pass that eliminates redundant operations (dead code)
  • Verify output numerical equivalence before and after transformation

Key concepts

ConceptWhat to understand
FX IRThree-address code representation of PyTorch operations
Node kindsplaceholder, get_attr, call_function, call_module, output
Graph rewritingInsert/erase nodes; update users; maintain invariants
Operator fusionFused kernels reduce memory bandwidth (key for NPU perf)

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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_op decorator
  • classify_nodes — analyze FX graph for NPU vs CPU ops
  • transform_graph — replace ATen ops with registered NPU ops via FX node substitution
  • npu_backendtorch.compile(model, backend=npu_backend) callable
  • verify_backend_correctness — compare eager vs compiled numerics

Key concepts

ConceptWhat to understand
Backend callable(gm: GraphModule, example_inputs) → Callable
ATen opsaten.mm.default, aten.relu.default — canonical op names
Node replacementgraph.inserting_after(node) + node.replace_all_uses_with
Correctness gateMax absolute diff < 1e-4 between eager and compiled

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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 + Linear in a single kernel
  • Tile-based computation for cache efficiency
  • Memory bandwidth analysis: fused vs unfused
  • Benchmarking with triton.testing.Benchmark

Key concepts

ConceptWhat to understand
Triton programming modelBlocks of threads operate on tiles
tl.load / tl.storeCoalesced memory access patterns
Fusion benefitAvoid extra HBM round-trips for intermediate results
@triton.jitJIT-compiles kernel at first call

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite (CUDA-only)
requirements.txtDependencies

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 NodeProto with 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 ONNX
  • onnxruntime: 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

FieldValue
GoalBuild a complete ONNX export, validation, and optimization pipeline for LLaMA-style models; implement 3 custom graph rewrite passes; validate accuracy end-to-end
Conceptstorch.onnx.export, onnx.checker, onnxoptimizer, onnxruntime, opset compatibility, dynamic axes, custom ONNX operators
Steps1. 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
StackPyTorch 2.3+, onnx, onnxoptimizer, onnxruntime, onnxruntime-gpu
DatasetsSynthetic + WikiText-2 val (first 100 sequences)
Outputllama_tiny.onnx; optimized llama_tiny_opt.onnx; accuracy/latency comparison table; visualization of graph before/after optimization
How to Testpytest 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 PointsWhat 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 BulletBuilt 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
ExtensionsAdd QDQ nodes for INT8 ONNX quantization; add TensorRT EP execution; integrate with ONNX Runtime's QNN Execution Provider

Lab 02 — TVM Compilation Pipeline

FieldValue
GoalCompile 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
ConceptsTVM Relay IR, relay.frontend.from_pytorch, relay.build, tuning records, autotvm.LocalRunner, ansor.TaskScheduler, TVM runtime module, cross-compilation for ARM
Steps1. 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
StackTVM 0.15+, PyTorch 2.3+, torchvision
DatasetsImageNet val subset (timing)
OutputTVM 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 Testpytest 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 PointsWhat 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 BulletCompiled 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
ExtensionsAdd 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

FieldValue
GoalWrite 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
ConceptsMLIR Python bindings (mlir-python-bindings), dialects, IR builder API, pass pipeline, linalg.generic, affine.for, memref.alloc, arith ops, bufferization
Steps1. 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
Stackmlir-python-bindings (from LLVM 17+), llvmlite
DatasetsSynthetic matrices
OutputComplete lowering pipeline from linalgaffinellvm → native; custom unroll pass; JIT execution with correctness verification
How to Testpytest 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 PointsWhat 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 BulletBuilt 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
ExtensionsTarget 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.py suites 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

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:

LevelUnit of programExample IRsOptimizations that live here
GraphoperatorsONNX, Relay, FX, MLIR tosa/stablehlofusion, constant folding, layout (NCHW↔NHWC), quantization insertion, partitioning
Tensor/looploops + buffersTIR, Inductor IR, MLIR linalg/affinetiling, vectorization, loop reordering, memory planning, double buffering
TargetinstructionsLLVM IR, PTX, Hexagon ISAregister 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:

  1. Draw the three-level lowering ladder and place ONNX, Relay, TIR, FX, linalg, and a vendor DLC on it.
  2. State the information-loss principle and give the matmul→loops→"can't call cuBLAS" example.
  3. Explain opsets and two real ONNX export bug families, with the validation discipline that catches them.
  4. Explain compute/schedule separation and why tiling reduces DRAM traffic with the block-reuse argument.
  5. Define an MLIR dialect, name five, and read a linalg.matmul aloud.
  6. 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

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:

  1. Each op is a separate CUDA kernel launch (overhead: ~5 µs per launch)
  2. Elementwise op sequences allocate intermediate tensors (memory overhead)
  3. The scheduler doesn't know about future ops — can't make global decisions

ML compilers solve this by:

  1. Capturing the full computation graph (no Python interpreter overhead)
  2. Optimizing globally across ops (operator fusion, constant folding, layout optimization)
  3. 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 + profiling
  • qai_hub.compile_model(): compile for specific device (e.g., "Snapdragon 8 Gen 3")

Qualcomm-specific constraints:

  1. Op support: not all ONNX ops are supported on HTP; check QNN op support matrix
  2. Data types: HTP prefers INT8/INT16 weights; FP16 ops run on HVX or Cortex-A cores
  3. Static shapes: HTP compilation requires static input shapes (no dynamic batch)
  4. Graph partitioning: ops not supported on HTP fall back to CPU; too many fallbacks → poor performance

Section 6: Interview Pitfalls

QuestionWrong AnswerRight 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

  1. ONNX specification — https://onnx.ai/onnx/intro/concepts.html
  2. onnxoptimizer passes — https://github.com/onnx/optimizer/tree/master/onnxoptimizer/passes
  3. TVM tutorial — https://tvm.apache.org/docs/tutorial/index.html
  4. AutoScheduler (Ansor) paper — Lianmin Zheng et al., "Ansor: Generating High-Performance Tensor Programs for Deep Learning" (OSDI 2020)
  5. MLIR primer — https://mlir.llvm.org/docs/Tutorials/Toy/
  6. torch-mlir — https://github.com/llvm/torch-mlir
  7. QNN SDK documentation — https://docs.qualcomm.com/bundle/publicresource/topics/80-63442-2/
  8. "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

ConceptWhat to understand
ONNX opsetVersioned set of ops — opset 17 is required for newer transformer ops
torch.onnx.exportTraces model; use dynamo_export for torch.compile models
ORT optimizationsOrtOptimizationLevel.ORT_ENABLE_ALL applies graph-level rewrites
Dynamic shapesdynamic_axes allows variable batch/sequence length

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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

ConceptWhat to understand
Relay IRFunctional IR; higher-level than LLVM IR
TE (Tensor Expressions)Schedule-based computation description
AutoTVM / MetaScheduleML-guided kernel auto-tuning
Compilation pipelinerelay.optimizete.create_prim_functvm.build

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite (skipped if TVM not installed)
requirements.txtDependencies

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.matmul and arith.addf IR programmatically
  • Count and classify ops in an MLIR module (with regex fallback for environments without MLIR installed)
  • Convert PyTorch FX graph to MLIR func.func dialect
  • Apply MLIR transformation passes

Key concepts

ConceptWhat to understand
DialectsNamespaced op sets: func, arith, linalg, memref, tosa
MLIR vs LLVM IRMLIR is multi-level; ops can be progressively lowered
tosa dialectTarget-agnostic ops used by CoreML, QNN, IREE
Lowering passeslinalg → loops → llvm — each step is a dialect transformation

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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:

  1. PyTorch/TF model → ONNX or TFLite
  2. ONNX → SNPE / QNN (Qualcomm Neural Processing SDK / Qualcomm AI Engine Direct)
  3. QNN SDK performs quantization + graph partitioning → DLC (Deep Learning Container)
  4. 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 .encodings JSON file specifying per-layer scale/offset for weights and activations; generated by the snpe-dlc-quantize or qnn-onnx-converter tools
  • 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

FieldValue
GoalBuild a complete SNPE/QNN deployment pipeline: PyTorch → ONNX → DLC → quantized DLC → benchmark accuracy and latency on Qualcomm AI Hub
Conceptssnpe-onnx-to-dlc, snpe-dlc-quantize, encodings JSON, Qualcomm AI Hub Python API, accuracy delta FP32 vs INT8 on HTP
Steps1. 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
Stackqai_hub (Qualcomm AI Hub SDK), snpe (or QAI AppBuilder), PyTorch 2.3+, ONNX
DatasetsImageNet 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 Testpytest 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 PointsWhy 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 BulletBuilt 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
ExtensionsDeploy 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

FieldValue
GoalWrite 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
ConceptsOp capability tables, data transfer latency model, networkx for graph analysis, integer linear programming (ILP) for optimal partitioning, heuristic greedy partitioner
Steps1. 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
StackPyTorch 2.3+, onnx, networkx, scipy, matplotlib
DatasetsONNX graphs of: MobileNetV3, ResNet-50, BERT-base, LLaMA-tiny, ViT-Small
OutputPartition assignments for 5 models; greedy vs ILP latency comparison; colored graph visualization
How to Testpytest 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 PointsWhy is the partitioning problem NP-hard? When does greedy outperform ILP (hint: overhead)? How does the actual QNN SDK handle partitioning?
Resume BulletImplemented 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
ExtensionsAdd 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

FieldValue
GoalFor 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
ConceptsOp 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
Steps1. 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
Stackqai_hub, onnx, PyTorch 2.3+, onnxoptimizer
DatasetsLibriSpeech test-clean (for Whisper); MMLU (for LLaMA)
OutputOp support report for 2 models; decomposition implementations; accuracy table before/after decomposition; final deployment recommendation
How to Testpytest test_lab.py — checks: decomposed models are ONNX valid, GeLU approximation error < 1e-3, fallback cost correctly accounts for CPU↔HTP data transfer
Talking PointsHow 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 BulletSystematically 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
ExtensionsWrite 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.py suites 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

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):

OperationEnergy
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:

  1. DRAM access costs ~100× the arithmetic. Accelerator design is therefore memory choreography — keep data on-chip, reuse it maximally. Compute is nearly free.
  2. 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:

UnitCharacterRight workload
Kryo CPUflexible, lowest throughputcontrol flow, preprocessing, tiny models, fallback ops
Adreno GPUFP16-friendly, mid throughput/efficiencyfloat models, image ops, when NPU coverage is poor
Hexagon HTP (NPU)INT8/INT4 + limited FP16, highest TOPS/Wquantized CNNs/transformers — the deployment target
Sensing hubalways-on, microwattswake-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):

  1. Partitioning: which ops run on HTP vs fall back (Chapter 8).
  2. 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.
  3. 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.
  4. 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.
  5. 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):

  1. Get the partition report (which ops, which runtime, how many islands).
  2. If islands > ~2–3, find the breakers: usually shapes (dynamic dims, rank >4), exotic ops (erf-GELU, complex slicing), or dtype transitions.
  3. 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:

  1. Reproduce the energy table's two conclusions (DRAM ~100× arithmetic; INT8 ~10–30× FP32) and derive accelerator design from them.
  2. Sketch the NPU anatomy (MAC array, TCM, vector unit, DMA) and explain why TCM is software-managed.
  3. Name the four Snapdragon compute targets and a placement rule for each.
  4. Walk the deployment pipeline naming what can silently break at each arrow.
  5. List four hardware quantization constraints that don't exist in Phase 03's pure math.
  6. 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

FeatureSNPE (Snapdragon NPE SDK)QNN (Qualcomm Neural Network SDK)
Generation2nd gen3rd gen (current)
Supported devicesSnapdragon 855–888+Snapdragon 8 Gen 1+
BackendCPU, GPU, DSP, NSPCPU, GPU, HTP, sHTP
Model formatDLCModel + Backend Libraries
QuantizationPer-layer INT8Per-channel INT8/INT16/FP16
Dynamic shapesLimitedBetter support
RecommendationLegacy/embeddedUse 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:

  1. Run initial profile → identify top-5 slowest layers
  2. Check compute_unit for each slow layer — if "CPU", that op is falling back
  3. 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)
  4. For slow HTP layers: check data type — FP16 is 4× slower than INT8 on HMX

Section 4: Op Mapping — PyTorch to HTP

PyTorch OpONNX OpHTP SupportNotes
nn.LinearGemm✅ INT8/FP16Primary ML op
nn.Conv2dConv✅ INT8/FP16
nn.LayerNormLayerNormalization✅ FP16Slower than BN
nn.BatchNorm2dBatchNormalization✅ INT8Fuse into Conv
torch.softmaxSoftmax✅ FP16
torch.sigmoidSigmoid✅ INT8/FP16
torch.reluRelu✅ INT8/FP16
F.geluGelu✅ FP16
nn.MultiheadAttentionAttention (contrib)⚠️ PartialUse QNN's custom attention kernel
torch.einsumN/ADecompose to matmul + transpose
torch.uniqueUniqueCPU fallback — avoid in hot path
tensor.nonzero()NonZeroDynamic shape — CPU only
Custom Python opsN/AAlways CPU — eliminate these
F.scaled_dot_product_attentionAttention✅ FP16Use 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.Device options include performance_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

QuestionWrong AnswerRight 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

  1. Qualcomm AI Hub documentation — https://aihub.qualcomm.com/docs
  2. QNN SDK — https://docs.qualcomm.com/bundle/publicresource/topics/80-63442-2/
  3. Qualcomm Innovation Center — AI Models — https://github.com/quic/ai-hub-models (100+ models with AI Hub integration)
  4. "Dissecting the Qualcomm Snapdragon NPU" — AnandTech deep-dive
  5. SNPE documentation — https://docs.qualcomm.com/bundle/publicresource/topics/80-63442-50/
  6. Hexagon DSP SDK — https://developer.qualcomm.com/software/hexagon-dsp-sdk
  7. 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

ConceptWhat to understand
SNPE vs QNNSNPE = legacy DLC format; QNN = new binary context format
NPU targetsSnapdragon 8 Gen 3 (SM8650) has 45 TOPS NPU
Op supportNot all ONNX ops supported on NPU; unsupported fall to CPU/GPU
QAI Hub jobscompile_jobprofile_jobinference_job pipeline

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

Run

pip install -r requirements.txt
python lab.py
pytest test_lab.py -v

Note: Full QAI Hub tests require a qai_hub account 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

ConceptWhat to understand
Symmetric quantNPU typically requires symmetric (zero_point=0) — simpler HW
Power-of-2 scalesSome NPUs require scales = 2^k for efficient shifting
Per-layer sensitivityMeasure ‖W_q @ X - W @ X‖ per layer to find sensitive layers
Mixed precisionINT4 for tolerant layers, INT8 for sensitive — maximize compression

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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

ConceptWhat to understand
Roofline modelPeak TOPS vs memory BW — which resource is the bottleneck?
Arithmetic intensityFLOPS / bytes moved — must exceed ridge point for compute-bound
HTA (Hexagon Tensor Accelerator)Qualcomm's NPU — operates on INT8/INT4 fixed-point
DSP vs NPUSome ops run on HVX DSP (vector); others on HTA (tensor)

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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 structure
  • ncu (Nsight Compute): per-kernel profiler; shows occupancy, memory throughput, warp stalls, FLOP counts; best for diagnosing individual kernel inefficiency
  • torch.profiler: PyTorch's built-in profiler; traces op-level timing with CUDA kernels; with torch.profiler.profile(activities=...) context manager
  • torch.utils.benchmark: Timer class 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

FieldValue
GoalBuild 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
ConceptsMemory bandwidth measurement (large memcpy benchmark), FLOP counting via torch.profiler, arithmetic intensity per op, roofline visualization, bottleneck classification
Steps1. 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
StackPyTorch 2.3+, CUDA GPU, matplotlib, torch.utils.flop_counter
DatasetsSynthetic
OutputRooflineAnalyzer class with analyze(model, inputs) method; roofline plot as PNG; per-op FLOPS, bandwidth, arithmetic intensity, bottleneck label
How to Testpytest 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 BulletBuilt 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
ExtensionsAdd 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

FieldValue
GoalBuild 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
Conceptstorch.profiler.profile, ProfilerActivity, CUPTI (CUDA Profiling Tools Interface) traces, torch.profiler.record_function, Chrome trace JSON, torch.cuda.memory_stats(), synchronization detection
Steps1. 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)
StackPyTorch 2.3+, torch.profiler, json
DatasetsSynthetic
OutputProfilingContext class; Chrome trace JSON loadable in chrome://tracing; console report with top-10 slow ops, memory peak, sync violations
How to Testpytest 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 BulletBuilt custom profiling harness that detected 47 synchronization violations in a production model; eliminating them reduced step time by 23%
ExtensionsAdd 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

FieldValue
GoalApply 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
ConceptsKV cache impact, attention memory complexity, torch.compile speedup, operator fusion opportunities, batch size effects on arithmetic intensity, memory layout (contiguous vs non-contiguous tensors)
Steps1. 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
StackPyTorch 2.3+, Lab 01 and 02 harnesses
DatasetsWikiText-2 (for generation timing)
OutputBefore/after timing table for each of 5 optimizations; cumulative speedup chart; final inference throughput vs theoretical peak
How to Testpytest 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 PointsWalk 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 BulletProfiled 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×)
ExtensionsAdd 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.py suites 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: nsys for timeline, ncu for kernel, torch.profiler for 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

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.profiler correlates GPU events properly via CUPTI; manual timing must use torch.cuda.Event or synchronize() 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):

  1. Warmup before measuring: JIT compilation, cache fill, clock ramp all pollute early iterations (cf. JVM warmup — same physics, different stack).
  2. Synchronize at boundaries (GPU async trap above).
  3. Report distributions, not means: median + p95/p99 over ≥30 iterations; latency distributions are skewed and tails are what users feel.
  4. 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.
  5. Change one variable per experiment and keep a results table. "I changed three things and it's faster" teaches nothing and regresses later.
  6. 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:

BugSignature in profileFix
Sync-in-loop (.item(), .cpu(), print of a tensor)GPU gaps every iteration; cudaStreamSynchronize spammove logging off the hot path; accumulate on device
Recomputed attention masks / RoPE tables per stepidentical small kernels repeated; CPU time building tensorsprecompute once, cache buffers
Missing KV cache (recompute full attention per token)decode latency grows linearly with generated lengthimplement the cache (Phase 02 Ch. 3)
Unfused elementwise chainsmany short bandwidth-bound kernels back-to-backtorch.compile / manual fusion
Wrong-layout copies (contiguous() storms)memcpy/transpose kernels between real workfix the producing op's layout
FP32 where FP16/BF16 intended2× the expected kernel time, 2× bytesaudit dtypes at module boundaries
Accidental dynamic shapesrecompilation stalls (Phase 04), allocator churnpad/bucket shapes
CPU dataloader starving the GPUGPU idle, low power draw, dataloader threads hotprefetch, 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.profiler on 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:

  1. Derive arithmetic intensity for elementwise, square matmul, and matvec, and place each on a roofline.
  2. Compute a device's ridge point from its datasheet and state what it means.
  3. Explain prefill vs decode bounds and give two optimizations per regime that don't help the other.
  4. State the GPU async-timing trap and both correct measurement methods.
  5. Recite five rows of the bug taxonomy with signatures.
  6. 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

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:

  1. Compute throughput: how fast the ALUs can multiply (FLOPS/s)
  2. 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

OperationFLOPSBytes ReadBytes WrittenAI (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 largeCompute
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 batchCompute

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 utilization
  • l1tex__t_bytes_pipe_lsu_mem_global_op_ld.sum: bytes loaded from L1 cache
  • dram__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():

  1. Handles warmup automatically (GPU JIT compilation, caching)
  2. Reports statistics (mean, median, IQR)
  3. Handles CUDA synchronization correctly (calls torch.cuda.synchronize() before timing)
  4. Uses perf_counter_ns for 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:

  1. 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)
  2. Shared memory: SM has 192 KB; if kernel uses 48 KB per block: SM can run 4 blocks
  3. 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() returns False; 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 use torch.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

QuestionWrong AnswerRight 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

  1. Roofline Model paper — Samuel Williams et al., "Roofline: An Insightful Visual Performance Model for Floating-Point Programs and Multiprocessor Architectures" (CACM 2009)
  2. NVIDIA Nsight Compute documentation — https://docs.nvidia.com/nsight-compute/
  3. NVIDIA Nsight Systems documentation — https://docs.nvidia.com/nsight-systems/
  4. torch.profiler tutorial — https://pytorch.org/tutorials/recipes/recipes/profiler_recipe.html
  5. torch.utils.benchmark documentation — https://pytorch.org/docs/stable/benchmark_utils.html
  6. "CUDA C++ Best Practices Guide" — NVIDIA (memory coalescing, occupancy, shared memory)
  7. "GPU Performance Background" — Tim Dettmers blog: https://timdettmers.com/2020/09/07/which-gpu-for-deep-learning/
  8. 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 specs
  • compute_arithmetic_intensity — FLOPS / bytes for a set of operations
  • classify_bottleneck — compute-bound vs memory-bound per layer
  • roofline_plot — text-based roofline visualization
  • Analysis of transformer layers: attention (memory-bound) vs linear (compute-bound)

Key concepts

ConceptWhat to understand
Ridge pointpeak_FLOPS / peak_BW — arithmetic intensity where compute=memory bound
Memory-bound opsElementwise (relu, add), small matmuls, LayerNorm
Compute-bound opsLarge matmuls, batched GEMM
ImplicationMemory-bound → quantize/fuse; compute-bound → tile/parallelize

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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.profiler output 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

ConceptWhat to understand
Chrome trace format{"name", "ph", "ts", "dur", "pid", "tid"} events
torch.profiler.profileContext manager for PyTorch profiling
Kernel launch overheadCPU→GPU launch latency — batch ops to amortize
record_functionCustom trace annotations for user-defined regions

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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:

  1. Incorrect attention mask causing O(T²) work even for padding tokens
  2. Missing .contiguous() before reshape causing unnecessary copies
  3. CPU-GPU sync via .item() inside the forward loop
  4. Redundant recomputation of sqrt(d_k) on every call
  5. Inefficient KV projection using separate linear layers instead of one

Key concepts

ConceptWhat to understand
Memory contiguityNon-contiguous tensors require copy before CUDA kernels
.item() syncBlocks GPU pipeline — never call in inference loop
Flash-attention maskCausal mask should be precomputed and registered as buffer
GEMM batchingOne [d, 3*d_k] projection is faster than three [d, d_k] projections

Files

FilePurpose
lab.pyBuggy transformer implementation with TODO: fix markers
solution.pyFixed implementation with explanations
test_lab.pypytest suite checking correctness and performance
requirements.txtDependencies

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

FieldValue
GoalImplement 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$
ConceptsTiled attention, online softmax with running max/normalizer, SRAM vs DRAM bandwidth, Triton tile sizes, causal masking optimization, tl.dot, tl.load/store with masks
Steps1. 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
StackTriton 2.3+, PyTorch 2.3+, CUDA GPU (≥16 GB VRAM for seq_len=8192 comparison)
DatasetsSynthetic
Outputflash_attn_forward.py Triton kernel; numerical correctness report; memory comparison plot (quadratic vs linear); speedup table
How to Testpytest 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 BulletImplemented 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
ExtensionsAdd multi-query attention (MQA) variant; implement sliding window attention (Mistral); add ALiBi bias support; implement backward pass

Lab 02 — Speculative Decoding Engine

FieldValue
GoalImplement 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
ConceptsToken acceptance sampling, rejection sampling proof (lossless = same distribution as target), acceptance rate measurement, draft model selection criteria, adaptive $k$
Steps1. 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
StackPyTorch 2.3+, transformers
DatasetsWikiText-2, ShareGPT (for realistic prompt distribution)
OutputSpeculativeDecoder class; acceptance rate vs prompt type analysis; throughput comparison table
How to Testpytest 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 BulletImplemented 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
ExtensionsImplement 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

FieldValue
GoalBuild 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
ConceptsContinuous 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
Steps1. 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)
StackPyTorch 2.3+, transformers, asyncio
DatasetsShareGPT request trace (simulated arrival times)
OutputServingEngine class; throughput vs latency plot for static vs continuous batching; KV cache utilization over time
How to Testpytest 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 BulletBuilt 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
ExtensionsAdd 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.py suites 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

Standard attention materializes the score matrix:

  1. $S = QK^\top$ — an $n \times n$ matrix, written to DRAM ($O(n^2)$ bytes).
  2. $P = \text{softmax}(S)$ — read $n^2$, write $n^2$.
  3. $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:

  1. Write safe softmax and the online update for $(m, \ell, O)$, and explain the rescaling factor's role.
  2. State FlashAttention's traffic result ($O(n^2) \to O(n^2 d/M)$) and why FLOPs go up.
  3. Derive the speculative acceptance rule and sketch the exactness proof.
  4. Compute expected accepted tokens for given $(\alpha, \gamma)$.
  5. Explain iteration-level scheduling and both static-batching pathologies.
  6. 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

QuestionWrong AnswerRight 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

  1. FlashAttention paper — Tri Dao et al., "FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness" (NeurIPS 2022)
  2. FlashAttention-2 — Tri Dao, "FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning" (ICLR 2024)
  3. FlashAttention-3 — Jay Shah et al., "FlashAttention-3: Fast and Accurate Attention with Asynchrony and Low-precision" (2024)
  4. Speculative decoding — Leviathan et al., "Fast Inference from Transformers via Speculative Decoding" (ICML 2023)
  5. vLLM / PagedAttention — Kwon et al., "Efficient Memory Management for Large Language Model Serving with PagedAttention" (SOSP 2023)
  6. Continuous batching — Orca blog post — https://www.usenix.org/conference/osdi22/presentation/yu
  7. Triton documentation — https://triton-lang.org/main/index.html
  8. 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 matrix
  • flash_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

ConceptWhat to understand
Online softmaxm_new = max(m, row_max); exp_sum update — avoids full row storage
SRAM tilingLoad block of K, V to fast SRAM; iterate over Q blocks
Memory savingsStandard: Q+K+V+attn_matrix = O(N²); Flash: only O(N) activations
RecomputationFlash recomputes attention during backward to save memory

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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 model
  • speculative_step — draft K tokens, target verifies in one forward pass, accept/reject with min(1, p_t/p_d), sample corrected distribution on rejection, add bonus token
  • speculative_generate — full generation loop
  • compare_distributions — total variation distance between distributions

Key concepts

ConceptWhat to understand
Accept/rejectToken t accepted with prob min(1, p_target(t) / p_draft(t))
Corrected distributionOn rejection: sample from max(0, p_t - p_d) / Z
Bonus tokenWhen all K accepted, target generates one extra token "for free"
Throughput gainWith high acceptance rate α, gain ≈ K·α + 1 tokens per target call
Draft-target alignmentMore similar models → higher α → more speedup

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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 storage
  • PagedKVCache — block table mapping sequences to KV blocks, LRU eviction when OOM
  • ContinuousBatcher — admit new requests mid-batch, evict finished ones each step
  • simulate_serving — end-to-end serving simulation with arrival/completion tracking

Key concepts

ConceptWhat to understand
KV cache fragmentationStatic allocation wastes memory — paged allocation wastes ~0%
Block tableSequence → list of physical block IDs (like virtual memory paging)
Continuous batchingAdd/remove sequences each step — no waiting for full batch
LRU evictionEvict least-recently-used blocks when new sequence can't fit
PreemptionSuspend running sequence, swap to CPU, resume when memory frees

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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

FieldValue
GoalImplement 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
ConceptsFew-shot prompt formatting, log-likelihood scoring (for multiple-choice), generation scoring (for open-ended), task registry, statistical aggregation
Steps1. 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%
StackPyTorch 2.3+, transformers, datasets
DatasetsMMLU, HellaSwag, WikiText-2 (all via HuggingFace datasets)
Outputeval_harness.py with Task, EvalHarness classes; results JSON matching lm_eval schema; comparison table showing agreement with official harness
How to Testpytest 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 BulletImplemented 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
ExtensionsAdd 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

FieldValue
GoalBuild a ParetoAnalyzer that sweeps multiple quantization configurations for a model family, measures accuracy and latency for each, and produces interactive Pareto frontier visualizations
ConceptsPareto optimality, hypervolume indicator, configuration sweep, interactive plotting (plotly), multi-objective optimization, dominated vs non-dominated solutions
Steps1. 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
StackPyTorch 2.3+, plotly, pandas
DatasetsMMLU, WikiText-2 (for quick accuracy proxies)
OutputInteractive 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 Testpytest 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 BulletBuilt 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
ExtensionsAdd energy consumption as a third axis; add per-task accuracy (not just aggregate); implement automated recommendation reporting

Lab 03 — Automated Accuracy Regression Pipeline

FieldValue
GoalBuild 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
ConceptsMcNemar's test, multiple testing correction (Bonferroni), accuracy threshold policy, CI integration (GitHub Actions YAML), baseline model checkpointing, regression report format
Steps1. 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
StackPyTorch 2.3+, scipy (McNemar's test), PyYAML, GitHub Actions
DatasetsMMLU subset (200 examples per task for fast CI run ~5 min)
Outputregression_pipeline.py; .github/workflows/accuracy-ci.yml; sample regression report in Markdown; demo: deliberately broken model caught by CI
How to Testpytest 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 BulletBuilt 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)
ExtensionsAdd 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.py suites pass

Guides in This Phase

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

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:

  1. Format the question (+ few-shot examples) as a prompt.
  2. 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).
  3. 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:

BenchmarkMeasuresFailure mode to know
MMLU (57 subjects, 4-choice)breadth of knowledgeanswer-position bias; formatting sensitivity; now partially saturated/contaminated
HellaSwagcommonsense completionadversarial filtering artifacts learnable by big models
GSM8Kmulti-step arithmetic reasoningscored by final-answer extraction — parsing bugs masquerade as reasoning deltas; CoT vs direct changes everything
ARC-Challengegrade-school sciencesmall (1.2K) → high variance (Ch. 5)
WinoGrandecoreference/world knowledgenear-duplicate train items in pretraining data
TruthfulQAresistance to misconceptionsMC 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 correctB 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:

  1. 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.
  2. 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.
  3. 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.
  4. Report: a markdown table per PR — aggregate delta with CI, per-task deltas, discordant counts, links to diffing examples. Humans override with recorded rationale.
  5. 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 Task formatting 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 official lm_eval numbers — 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:

  1. Write the PPL formula and name the three comparability traps (tokenizer, windowing, stride).
  2. Explain log-likelihood scoring and why length normalization variants change scores.
  3. Compute the SE of an accuracy on n items and the 95% CI; state why pairing beats it.
  4. Run McNemar by hand on a 2×2 discordant table (the worked example).
  5. Define Pareto dominance and defend the knee point as a default.
  6. 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

  1. Diverse: 57 subjects reduces task-specific overfitting
  2. Cheap: multiple-choice = no generation, fast to evaluate
  3. Well-calibrated: results are stable across runs (no sampling variance)
  4. Established: every major model reports it → direct comparison possible

Typical Numbers

ModelMMLU (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 correctModel 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

ScenarioTestWhy
Same examples, same task, comparing modelsMcNemarPaired binary
Different test sets, same model, comparing accuracyZ-test for proportionsUnpaired
Comparing across multiple tasksMcNemar + Bonferroni correctionMultiple testing
Comparing ranking across tasksWilcoxon signed-rankNon-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

QuestionWrong AnswerRight 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

  1. MMLU paper — Hendrycks et al., "Measuring Massive Multitask Language Understanding" (ICLR 2021)
  2. lm-evaluation-harness — https://github.com/EleutherAI/lm-evaluation-harness
  3. HellaSwag — Zellers et al., "HellaSwag: Can a Machine Really Finish Your Sentence?" (ACL 2019)
  4. GSM8K — Cobbe et al., "Training Verifiers to Solve Math Word Problems" (arXiv 2021)
  5. McNemar's test — Wikipedia has a clean derivation; also see: Dietterich, "Approximate Statistical Tests for Comparing Supervised Classification Learning Algorithms" (Neural Computation 1998)
  6. Open LLM Leaderboard — https://huggingface.co/spaces/HuggingFaceH4/open_llm_leaderboard — see canonical evaluation numbers
  7. 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

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:

  1. 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%.
  2. 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

ProblemWrong answerRight answer
Multiple metrics (accuracy, latency, memory, power)Weighted sumPareto analysis
Statistical significance (is the accuracy delta real?)Compare raw numbersMcNemar'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:

ObjectiveDirectionWhy you care
Accuracy (F1, top-1, mAP, etc.)MaximizeCore task quality
Inference latency (ms per token)MinimizeUser experience, SLA
Peak memory (MB RAM)MinimizeFits on device
Model size (MB on disk)MinimizeDownload / OTA update cost
Power draw (mW)MinimizeBattery life on mobile
Throughput (tokens/sec)MaximizeServer 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:

MetricDirectionTool
Accuracy (task-specific)MaximizeYour eval harness
Latency (ms/inference)MinimizeQAI Hub profiler, SNPE benchmark
Memory (peak RAM, MB)MinimizeQAI Hub, runtime instrumentation
Power (mW)MinimizeQualcomm profiler
Model size (MB)MinimizeFile size
Throughput (fps)MaximizeDerived 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:

  1. Effect size (δ): The magnitude of the true difference. Smaller differences require more data.
  2. Sample size (N): More data = more power.
  3. Significance level (α): Lower α = harder to reject H₀ = less power.
  4. 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:

  1. Decide the minimum accuracy delta worth detecting (e.g., 1%)
  2. Estimate the expected disagreement rate between models (e.g., 10%)
  3. Choose desired power (80% is standard)
  4. 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

  1. 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.

  2. 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.

  3. 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

  1. Pareto, V. (1896). Cours d'économie politique. — Original formulation of Pareto efficiency.

  2. 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.

  3. 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

  1. 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.

  2. Kohavi, R., & Provost, F. (1998). "Glossary of Terms." Machine Learning, 30, 271–274. — Definitive ML terminology for evaluation metrics.

  3. 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

  1. 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.

  2. Liang, P., et al. (2022). "Holistic Evaluation of Language Models (HELM)." arXiv:2211.09110. — Canonical multi-metric, multi-objective evaluation framework for LLMs.

  3. 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

  1. Qualcomm AI Hub Documentationaihub.qualcomm.com — Model profiling, on-device benchmarking, latency/memory/power metrics. The practical reference for Qualcomm NPU evaluation.

  2. Reddi, V. J., et al. (2020). "MLPerf Inference Benchmark." arXiv:1911.02549. — Industry standard for multi-metric, hardware-aware model evaluation.

Python / PyTorch References

  1. SciPy documentation — scipy.stats.chi2 — CDF, PPF, and PDF for chi-squared distribution used in McNemar's p-value computation.

  2. PyTorch documentation — Broadcasting semanticspytorch.org/docs/stable/notes/broadcasting — Explains the unsqueeze + 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 function
  • EvalHarness — runs multiple tasks, aggregates per-task and overall scores
  • Integration with lm_eval format (few-shot benchmarks: MMLU, HellaSwag, ARC)
  • Regression detection: compare against baseline with configurable delta threshold
  • JSON report generation for CI artifacts

Key concepts

ConceptWhat to understand
lm-evaluation-harnessOpen-source framework for standardized LLM benchmarks
Few-shot promptingEvaluate with N in-context examples — tests generalization
Accuracy vs F1MMLU uses accuracy; NER/QA use F1; generation uses ROUGE/BLEU
CI integrationEval must be reproducible and deterministic for regression detection

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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 with accuracy, latency_ms, model_name
  • dominates(a, b) — Pareto dominance: a dominates b iff both ≥ and at least one strict
  • compute_pareto_front — filter out all dominated models
  • find_knee_point — maximize distance from utopian point (1,1) in normalized space
  • weighted_score — combine metrics with configurable weights
  • rank_by_weighted_score — sorted ranking for decision-making

Key concepts

ConceptWhat to understand
Pareto dominancea dominates b: a is no worse in all objectives and better in ≥1
Pareto frontSet of non-dominated models — the "best tradeoffs"
Knee pointModel closest to utopian (max accuracy, min latency) — practical default
Utopian point(best_accuracy, best_latency) — unreachable but guides selection

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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 comparisons
  • RegressionDetector — stores baseline, compares new model per-task
  • RegressionPolicy — per-task delta gate + average delta gate for CI pass/fail
  • analyze_slices — break down accuracy by data slice (domain, length, etc.)
  • RegressionResult — full report with pass/fail status

Key concepts

ConceptWhat to understand
McNemar's testFor paired binary outcomes: χ²=(
b, c cellsb = old correct, new wrong; c = old wrong, new correct
Why not t-test?McNemar handles correlated binary pairs — t-test assumes independence
CI regression gatep < 0.05 AND delta > threshold → block deployment

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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

FieldValue
GoalQuantize 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
ConceptsGPTQ vs AWQ vs NF4 at INT4, memory footprint calculation, tokens/sec on NPU, accuracy delta (PPL, MMLU, WinoGrande), size/accuracy/latency trade-off table
Steps1. 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
Stackauto-gptq, autoawq, bitsandbytes, qai_hub, transformers
DatasetsWikiText-2, MMLU (200 questions for speed), WinoGrande val
OutputComparison 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 Testpytest 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 BulletBenchmarked 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
ExtensionsAdd 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

FieldValue
GoalAfter 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
ConceptsLoRA 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
Steps1. 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
Stackpeft, bitsandbytes, transformers, trl
DatasetsMedQA (medical questions) or CourtListener (legal), WikiText-2
OutputLoRA-recovered model; accuracy vs rank plot; domain accuracy before/after recovery; training curves; merged deployable model
How to Testpytest 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 BulletApplied 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)
ExtensionsAdd 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

FieldValue
GoalImplement 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
ConceptsLayer 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
Steps1. 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
StackPyTorch 2.3+, asyncio, mmap, transformers
DatasetsWikiText-2 long documents (1000+ tokens)
OutputStreamingTransformer class; latency vs DRAM budget trade-off curve; prefetch effectiveness analysis; minimum viable DRAM configuration
How to Testpytest 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 BulletImplemented 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
ExtensionsAdd 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.py suites 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

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:

ItemFP16INT4 (g=128)
Weights2.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
Total3.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.

  1. Quantize the base model (AWQ INT4, Lab 01). Measure the accuracy gap vs FP16.
  2. 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.
  3. 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:

  1. Budget weights/KV/activations for any model on a given device and state the decode tok/s ceiling from bandwidth alone.
  2. Write the AWQ scaling identity, say where $1/s$ folds, and why scaling beats mixed-precision storage on NPUs.
  3. Write the LoRA update with init and the role of $\alpha/r$; state the merge-vs- separate tradeoff.
  4. Explain NF4 in one sentence (normal quantiles → equal probability mass per level) and what double quantization saves.
  5. Defend adapter-based quantization recovery vs QAT on cost and artifact-management grounds.
  6. 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):

  1. Compute $H = X^T X$ from calibration data
  2. 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:

  1. Find salient input channels: $\text{saliency}j = |X{:,j}|2 \cdot |W{j,:}|_2$ (activation × weight magnitude)
  2. 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)
  3. 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

QuestionWrong AnswerRight 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

  1. QLoRA paper — Dettmers et al., "QLoRA: Efficient Finetuning of Quantized LLMs" (NeurIPS 2023)
  2. LoRA paper — Hu et al., "LoRA: Low-Rank Adaptation of Large Language Models" (ICLR 2022)
  3. GPTQ paper — Frantar et al., "GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers" (ICLR 2023)
  4. AWQ paper — Lin et al., "AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration" (MLSys 2024)
  5. StreamingLLM — Xiao et al., "Efficient Streaming Language Models with Attention Sinks" (ICLR 2024)
  6. bitsandbytes library — Tim Dettmers — https://github.com/TimDettmers/bitsandbytes
  7. llama.cpp — https://github.com/ggerganov/llama.cpp — reference implementation for edge LLM inference
  8. MLC LLM — https://github.com/mlc-ai/mlc-llm — TVM-based mobile LLM deployment
  9. 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 quantization
  • quantize_weight_int4 — clamp[-7,7] → round → dequantize
  • awq_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 layer
  • evaluate_quantization_error — relative error + cosine similarity

Key concepts

ConceptWhat to understand
Channel scalingMultiply salient weight channels by s before quantization (reduces error)
Activation-awares = x_max^α — scale determined by activation magnitude, not just weights
INT4 vs INT8INT4 = 2× more layers fit in NPU SRAM — critical for 7B+ edge LLMs
AWQ vs GPTQAWQ: activation scaling; GPTQ: Hessian-based correction — both SOTA

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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 adapter
  • train_qlora — freeze base, train A/B/bias only with AdamW
  • merge_lora — W_merged = W_base_deq + B@A * (α/r) for deployment

Key concepts

ConceptWhat to understand
LoRAW = W_base + B@A * (α/r); rank r << d — few trainable params
NF4Quantization grid optimized for normally-distributed weights
Block quantizationIndependent scale per 64 values — better accuracy than per-tensor
Double quantQuantize the quantization constants themselves (saves ~0.4 bpw)
Memory savings7B model: FP16=14GB, INT8=7GB, NF4=3.5GB VRAM

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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 layer
  • LayerPool — device-side LRU buffer holding at most W layers at once
  • StreamingTransformer — prefetch next layer while running current; LRU eviction
  • benchmark_streaming — measure latency and peak memory for different window sizes
  • select_max_layers_for_budget — choose largest window fitting memory budget

Key concepts

ConceptWhat to understand
Layer streamingLoad layer to GPU/NPU, run, evict — trade latency for memory
PrefetchingDMA next layer while computing current — hide transfer latency
Window sizeTradeoff: more layers cached = faster inference but more DRAM
LRU policyFor transformer sequential access, LRU ≈ FIFO
Flash bandwidthNVMe: ~7 GB/s; eMMC (phone): ~300 MB/s — bottleneck at small W

Files

FilePurpose
lab.pyStubs with TODO markers
solution.pyComplete working implementation
test_lab.pypytest suite
requirements.txtDependencies

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

CapstoneTarget RoleKey Skills DemonstratedTime
01 — Accuracy Regression CI SystemAny ML engineeringEval harness, statistical testing, CI/CD, regression detection2 weeks
02 — Full NPU Inference PipelineQualcomm / Edge AIPyTorch → ONNX → QNN → HTP profiling → accuracy delta3 weeks
03 — Custom torch.compile BackendFramework engineeringFX passes, compiler backend, mock ISA, code generation3 weeks
04 — Production Quantization SuiteML infrastructurePTQ + QAT + GPTQ + AWQ comparison, accuracy recovery2 weeks
05 — Multi-Model Eval + Pareto DashboardPlatform engineeringEval pipeline, Pareto analysis, interactive dashboard, CI2 weeks

Capstone 01 — Production Accuracy Regression CI System

Overview

A production-ready GitHub Actions workflow that:

  1. Runs eval harness (MMLU, HellaSwag, PPL) on a PR's model checkpoint
  2. Compares to the main-branch baseline using McNemar's test
  3. Fails the PR if any task drops >0.5% (customizable threshold) with p<0.05
  4. 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 01
  • eval/regression_check.py — McNemar's test + policy engine
  • eval/report_generator.py — Markdown report with tables and badges
  • .github/workflows/accuracy-ci.yml — full CI pipeline
  • eval/baselines/ — artifact store for baseline results
  • tests/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:

  1. Export to ONNX with full opset validation
  2. Optimize ONNX graph (fold constants, eliminate redundancies)
  3. Convert to Qualcomm AI Hub DLC format
  4. Profile HTP execution, identify bottleneck layers
  5. 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 validation
  • pipeline/optimize.py — ONNX graph optimization passes
  • pipeline/qai_submit.py — AI Hub submission + result polling
  • pipeline/profile_analyzer.py — parse HTP profile, identify bottlenecks
  • pipeline/quantize.py — INT8/INT4 pipeline with calibration
  • pipeline/accuracy_delta.py — accuracy delta measurement at each stage
  • pipeline/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 ops
  • LOAD_TILE, STORE_TILE — memory ops (DRAM ↔ VTCM)
  • QUANTIZE, DEQUANTIZE — precision ops
  • RELU, GELU, SOFTMAX, LAYERNORM — activation/norm ops
  • ELEMENTWISE_ADD, ELEMENTWISE_MUL, BROADCAST — elementwise
  • CONCAT, RESHAPE — shape ops

What to Build

  • npu_backend/isa.py — instruction set dataclasses + validator
  • npu_backend/lowering.py — FX node → ISA instruction mapper
  • npu_backend/fusion.py — graph optimization: fuse MATMUL + RELU, LAYERNORM + DROPOUT, etc.
  • npu_backend/codegen.py — ISA instruction list → JSON "NPU program" + pretty-printer
  • npu_backend/simulator.py — simulate execution: count cycles, memory traffic, VTCM pressure
  • npu_backend/register_backend.py — register as torch.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.compile backend 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

MethodBitsCalibrationTarget
Naive MinMaxINT8128 samplesBaseline
Percentile (99.9%)INT8128 samplesBetter tails
GPTQINT4128 samplesMemory-efficient
AWQINT4128 samplesSalient channels
SmoothQuantINT8128 samplesLLM-specific
QAT (simulated)INT8Full fine-tuneBest 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 Pareto
  • frontend/dashboard.html — interactive Plotly.js dashboard (no build system, single file)
  • server/alert_engine.py — detect regressions, send GitHub notifications
  • server/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


  1. Capstone 01 first — gets CI/CD infrastructure in place for all other projects
  2. Capstone 04 — builds on phases 02-03, most directly reuses lab code
  3. Capstone 02 — requires QAI Hub access, do after account setup
  4. Capstone 03 — hardest; do this when you're comfortable with FX and torch.compile
  5. Capstone 05 — integrates everything; do last

Portfolio Presentation

For each completed capstone:

  1. GitHub repo with clean README, architecture diagram, and demo GIF
  2. One-paragraph LinkedIn post linking to the repo
  3. One 5-minute demo video (Loom) showing the tool in action
  4. 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

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:

  1. 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.
  2. Numbers over claims: every capstone deliverable is a table or plot — accuracy before/after, latency distributions, Pareto fronts. "It works" is not a result.
  3. 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]} beats final_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):

  1. Freeze inputs: one fixed batch, fixed seed, through every stage.
  2. 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.
  3. 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.
  4. 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.
  5. 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

CapstoneComposesCertifies you can…
01 Regression CIPhase 09 harness + stats + CImake accuracy a gated property of a codebase
02 NPU graph optimizationPhases 04–06 (FX passes, partitioning, NPU constraints)optimize a graph for a target and prove the win on-profile
03 Custom compile backendPhase 04 backend + Phase 05 loweringown the capture→partition→codegen path end to end
04 Production quant suitePhase 03 + 10 methods under one interfacerun a method-vs-method bake-off with config discipline
05 Multi-model dashboardPhase 09 Pareto + the ledgerturn 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 demo or 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:

  1. Read the target capstone's upstream labs' solutions first — the capstone assumes their interfaces; re-deriving them mid-capstone doubles the time.
  2. 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.
  3. 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.
  4. 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:

  1. Each produces its headline artifact from a single entry point, reproducibly from config.
  2. The regression CI (01) demonstrably catches an injected 2% degradation and passes a clean change.
  3. The graph-optimization capstone (02) shows a before/after profile with the win attributed to named passes.
  4. The backend (03) compiles a LLaMA-style block with >95% op coverage and falls back gracefully on the rest.
  5. The quant suite (04) reproduces the Phase 03/10 method ranking on at least one model, from one CLI.
  6. The dashboard (05) renders the ledger as a Pareto front a non-engineer could choose from.
  7. 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

LabLikely 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

  1. Lab 01 (CI Pipeline) — builds on Phase 09, standalone
  2. Lab 05 (Multi-Model Dashboard) — builds on Lab 01
  3. Lab 02 (NPU Graph Optimization) — builds on Phase 05/06
  4. Lab 04 (Production Quant Suite) — builds on Phase 03/04
  5. 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 baselines
  • mcnemar_test — χ² with continuity correction for paired accuracy comparison
  • evaluate_regression — per-task delta + McNemar p-value
  • CIReport — JSON report with pass/fail per task and overall gate decision
  • build_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_OPS set — ops supported by the simulated NPU
  • analyze_fx_graph — symbolic trace, compute op coverage %
  • export_to_onnx — export with opset 17 to BytesIO buffer
  • compute_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.compile backend that routes eligible ops to a simulated NPU and falls back to CPU for unsupported ops.

What you build

  • _NPU_OPS registry + @register_npu_op decorator
  • npu_linear, npu_relu, npu_gelu — simulated NPU implementations
  • classify_nodes — partition FX graph into NPU-eligible vs CPU-fallback
  • transform_graph — replace ATen ops with NPU ops via FX node substitution
  • npu_backendtorch.compile(model, backend=npu_backend) callable
  • verify_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 PTQ
  • apply_ptq — quantize all Linear layers in model
  • LayerSensitivity + measure_layer_sensitivity — per-layer isolation (freeze all others)
  • PrecisionAssignment + assign_mixed_precision — drop>2%→FP16, 0.5-2%→INT8, <0.5%→INT4
  • apply_mixed_precision — assign different bit widths per layer
  • FakeQuantize (STE) + run_qat — fine-tune with fake quantization noise
  • QuantizationSuiteReport + 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 structures
  • run_benchmark_matrix — all variant×task combinations
  • aggregate_results — per-model averages, sorted by accuracy
  • dominates / compute_pareto_front / find_knee — Pareto analysis
  • detect_regression — compare to baseline with delta threshold
  • DashboardReport + 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

FileContentTime
01-concepts-cheatsheet.mdTop 40 Q&A — quantization, profiling, NPU, torch.compile, eval4 hours read
02-framework-internals-coding.pyLive coding: autograd, dispatch, FX passes from scratch6 hours practice
03-optimization-coding.pyLive coding: FlashAttention tiling, fused kernels, roofline6 hours practice
04-systems-design-questions.md10 system design walkthroughs with full answers4 hours read
05-research-engineering-questions.mdPaper-to-code challenges (FlashAttention, GPTQ, LoRA)8 hours practice
06-behavioral-star-stories.mdSTAR stories for each competency area2 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:

  1. Depth: do you know why, not just what?
  2. Breadth: can you connect quantization to hardware architecture to accuracy metrics?
  3. Leadership: have you influenced frameworks, teams, or technical direction?
  4. 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:

  1. Python: __add__ calls torch.add
  2. C++ dispatcher: resolves via dispatch key priority list (Functionalize → Autograd → CUDA/CPU)
  3. Autograd: creates AddBackward0 node in the autograd graph (if requires_grad)
  4. 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/buffer
  • call_function: free function call (torch.relu(x))
  • call_method: tensor method call (x.reshape(...))
  • call_module: nn.Module forward 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?

  1. Dynamic output shapes (e.g., nonzero(), unique())
  2. Op not in QNN's HTP library for the given data type (e.g., FP32 ops are CPU-only; use INT8 or FP16)
  3. Data type mismatch between compile-time specs and runtime values
  4. 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:

  1. It calls torch.cuda.synchronize() before and after to account for async GPU execution
  2. It runs warm-up iterations first (eliminates JIT compilation and caching artifacts)
  3. It reports mean, median, and IQR over multiple runs
  4. 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?

  1. INT4 quantization: 7B × 0.5 bytes = 3.5 GB (just fits)
  2. Use NF4 or AWQ for best accuracy at INT4
  3. Sliding window attention to limit KV cache growth
  4. Chunked prefill to bound peak memory
  5. 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.

  1. Pre-tapeout (simulation): run full accuracy suite on ISA simulator; compare to GPU golden reference; any regression blocks tapeout
  2. Post-tapeout bringup: run on 3–5 representative models; verify INT8 accuracy within 0.5% of FP16 baseline; identify HTP fallback ops
  3. Production release: run full benchmark suite (20+ models, 3 bit-widths each); publish accuracy comparison table; run McNemar tests for all significant deltas
  4. 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

#QuestionCore Concepts
1Accuracy regression CI pipelineEval harness, stats, CI/CD
2NPU deployment pipeline for SnapdragonSNPE/QNN, calibration, validation
3Quantization error recovery systemQAT, ADAROUND, sensitivity
4Real-time model serving at 100k QPSbatching, autoscaling, SLO
5Distributed offline evaluation platformRay/Spark, scheduling, dedup
6Model performance benchmarking infraStandardization, reproducibility
7Online accuracy monitoring for deployed modelsdrift detection, shadow eval
8GenAI on-device inference pipelinememory budget, INT4, streaming
9A/B testing framework for model variantstraffic splitting, significance
10Continual training + accuracy preservationEWC, 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

  1. Baseline pinning: Store baseline results per model variant in a versioned database (PostgreSQL + TimescaleDB). Never recompute baselines at PR time — they drift.

  2. Statistically valid comparisons: Use McNemar's test (paired accuracy), not just point estimate difference. Threshold: p < 0.05 AND |Δacc| > 0.5%.

  3. 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.

  4. Parallelism: Each benchmark runs on a separate GPU. For 10 benchmarks at 2 min each = 2 min wall time with 10 GPUs.

  5. 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

  1. Calibration data: Must be representative of production distribution. Use 500 images from each class for classification; 100 conversations for LLMs. Never use test set.

  2. 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.

  3. 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.

  4. Fallback strategy: CPU/GPU fallback for unsupported HTP ops. Profile: target >80% of ops on HTP.

  5. 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

PrecisionAccuracy DropLatency (HTP)File Size
FP320%50ms100%
FP16< 0.1%25ms50%
INT80.5-1.5%10ms25%
INT4 (W4A8)1-3%6ms12.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

ComponentBudget
Network (client → LB)5ms
Auth + rate limit2ms
Queue wait5ms
Prefill (50 tokens)20ms
Decode (20 tokens × 2ms)40ms
Network (LB → client)5ms
Total p5077ms

Key Optimizations

  1. Continuous batching (vs static batching): 30-50% throughput improvement
  2. KV cache paging (PagedAttention): eliminates memory fragmentation, 2× capacity
  3. Speculative decoding: 3-5× speedup for repetitive text at cost of draft model
  4. 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

ComponentSize (INT4)
7B params at W4A83.5 GB
KV cache (max 512 tokens, 32 layers, 2 heads, 128d)128 MB
Activations64 MB
Runtime overhead200 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

  1. GGUF format (llama.cpp) → direct quantization, no conversion needed
  2. QNN compilation: qnn-context-binary-generator for HTP caching
  3. First-run: compile + cache .qnncache binary (30s)
  4. 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:

  1. Sequence parallelism: parallelize over Q blocks (not just K/V blocks)
  2. Reduced non-matmul FLOPs: fewer rescaling operations
  3. 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:

  1. Compute per-channel activation magnitude: a_j = mean(|x_j|) across calibration data
  2. Find optimal scale: s_j = a_j^α (grid search α ∈ [0, 1])
  3. Transform: W' = W * diag(s), x' = x * diag(s)^{-1} (absorbed into previous LayerNorm)
  4. Quantize W' (now with more uniform magnitude)

Why it works:

  • Salient channels: large a_j → large s_j → small a_j * s_j^{-1} → less quantization error
  • Non-salient channels: small a_j → small s_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:

  1. TorchDynamo (Python bytecode → FX graph):

    • Intercepts Python bytecode at the RESUME instruction
    • 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
  2. AOTAutograd (forward + backward tracing):

    • Traces both forward and backward passes via make_fx and FunctionalizationInterpreter
    • Produces a single joint graph of forward + backward (for memory optimization)
    • Applies operator fusion and dead code elimination
  3. 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
  4. 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 unless dynamic=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, use torchdynamo.explain(fn, *args) to find breaks

B2: "Explain quantization-aware training (QAT) vs post-training quantization (PTQ)"

PTQQAT
Calibration data100-1000 samplesFull training set
Training requiredNoYes (fine-tuning)
Training timeMinutesHours-days
Accuracy (INT8)-0.5 to -1.5%-0.1 to -0.5%
Accuracy (INT4)-3 to -8%-1 to -3%
Use caseDeployment deadlines, no GPU timeProduction 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:

  1. 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 - acc
    

    Sort by sensitivity. If top-1 sensitive layer has 15% drop → that layer is the cause.

  2. 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
  3. Weight distribution inspection:

    • Plot weight histogram per layer: bimodal or heavy-tailed → hard to quantize
    • Fix: channel-wise quantization, GPTQ, AWQ
  4. 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")
    
  5. 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:

  1. Quantize at INT8, INT4, FP16 → 3 points on frontier
  2. Prune to 50%, 75%, 90% sparsity → more points
  3. 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:

  1. 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)
  2. Weight quantization (INT4): 7B × 0.5 bytes = 3.5 GB → 7ms/2 = 3.5ms/token

  3. Speculative decoding: draft model generates 4 tokens speculatively; target model verifies all 4 in one forward pass → effective throughput 3-5× higher

  4. Multi-query attention (GQA): KV cache reduction: 32-head → 4-head KV = 8× less KV reads

  5. 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:

  1. 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.
  2. Designed a mixed-precision strategy: INT4 for feed-forward layers (70% of parameters), FP16 for attention (sensitive), INT8 for embeddings.
  3. Introduced AWQ for the INT4 layers to handle activation outliers — reduced the W4A8 accuracy gap from 3.2% to 0.7%.
  4. Built an automated sensitivity + mixed-precision pipeline that ran overnight and produced a deployment artifact.
  5. 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:

  1. First, verified the regression was real: re-ran evaluation twice with different seeds to rule out measurement noise. Confirmed it was consistent (±0.1%).
  2. Checked git blame on the evaluation script — found a silent tokenizer version bump from transformers 4.38 to 4.40 that changed the special token handling (BOS token was now doubled in 4.40 for this model family).
  3. Wrote a minimal repro: 10-example test that showed the score difference was entirely explained by the tokenizer change (forward pass outputs were identical).
  4. 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.
  5. 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:

  1. 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.
  2. 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.
  3. Week 2 — combined pruning with INT4 quantization (AWQ). Result: 285 MB, within target.
  4. Accuracy: 3.1% drop — over budget. Tracked it to pruning ratio being too aggressive.
  5. Week 3 — ran per-layer sensitivity analysis; found 8 FFN layers were contributing disproportionately to the drop. Reverted those to 50% sparsity.
  6. Final: 310 MB (+3% over target), 1.8% accuracy drop.
  7. 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:

  1. 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.
  2. Requested 30 minutes at their team's tech review. Presented a one-page comparison: accuracy table, latency numbers, model size. No opinion — just data.
  3. Offered to contribute: "If you're open to it, I can add GPTQ support to your quantization pipeline in a week."
  4. They agreed to a 1-week experiment. I implemented GPTQ in their pipeline, validated on their test set.
  5. 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:

  1. 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."
  2. Shared the relevant sections from the lm_eval documentation and our internal best practices doc.
  3. Pair-programmed for 2 hours: walked through McNemar's test for statistical significance, why paired tests matter, how to set seeds correctly.
  4. Gave them ownership: "You're now the team's eval accuracy owner. When anyone runs an eval, you review the methodology."
  5. 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:

  1. 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.
  2. Distribution mismatch was the root cause — not the model architecture or the QLoRA setup.
  3. Organized a postmortem with the team: framed it as a data strategy failure, not a personal failure. Identified 3 things we'd do differently.
  4. 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.
  5. 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

FileScenarioKey Skills
01-model-accuracy-regression-platform.mdCI/CD platform for model accuracyeval harness, statistical testing, CI integration
02-npu-graph-optimization-pipeline.mdEnd-to-end PyTorch → NPU compilationcompiler, ONNX, QNN, profiling
03-quantization-accuracy-recovery-system.mdPTQ + QAT accuracy recovery servicequantization methods, calibration data management
04-distributed-eval-platform.mdMulti-model, multi-device eval infrastructuredistributed systems, job scheduling, result storage
05-model-perf-benchmarking-infra.mdProduction benchmarking infrastructurecontinuous benchmarking, A/B testing, Pareto tracking

How to Practice System Design

  1. 30 minutes to design: cover requirements, API, data model, core algorithm, scale
  2. Never show only happy path: always discuss failure modes, monitoring, rollback
  3. Quantify everything: "1000 model evals/day" not "many evals"
  4. Know the trade-offs: at Senior Staff level, there is no single right answer — you need to articulate WHY you made each architectural choice
  5. 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

FailureDetectionMitigation
GPU eval node failureHeartbeat + timeoutRetry on different node; alert if all retries fail
Model loading failureException in eval workerReport as eval error (not accuracy failure); don't block PR
Calibration dataset driftPeriodic re-calibration checkMonthly: run baseline eval on new and old calibration set; compare
False positive regressionPost-mortem analysisWeekly: review all failed CI checks; adjust thresholds if FP rate > 5%
Baseline contamination (buggy baseline)Baseline auditBefore 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

DecisionAlternativeWhy This Choice
McNemar's testZ-test for proportionsMcNemar accounts for correlation (same test examples) — more powerful for paired comparisons
PostgreSQLClickHouseComplex 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 baselinesShared baseline poolEach 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 .dlc or .qnncache artifact 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:

FailureRoot CauseFix
TypeError: Only Tensors...Python-level control flow in forward()torch.jit.trace first, or rewrite with tensor ops
Shape mismatch after exportDynamic shapes not declaredtorch.onnx.export(..., dynamic_axes={"input": {0: "batch"}})
Unsupported opCustom extension or experimental opRegister custom ONNX symbolic or decompose
Precision mismatchFP64 default for some opsCast 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

FailureDetectionMitigation
ONNX export failsBuild-time errorPre-check with torch.onnx.dynamo_export; decompose custom ops
HTP op coverage < 80%Analysis stepProfile CPU/GPU fallback cost; set threshold per model type
Accuracy gate failsLayer-by-layer CosSimAutomatic FP16 upgrade for failing layers; re-run pipeline
AI Hub rate limitHTTP 429 from Hub APIExponential backoff; queue jobs; use local SNPE SDK as fallback
Non-deterministic resultsRepeated runs differ > 0.1%Fix batch normalization in eval mode; set calibration seed
Model too large for HTPMemory profiler reportEnable layer streaming; reduce context length; prune first

Performance Characteristics

OperationTypical TimeHardware
torch.export() + graph analysis2-5 minCPU
Sensitivity analysis (50 layers)15-30 min1× A100
Calibration + INT8/INT4 quantization5-10 min1× A100
ONNX export + simplification2-3 minCPU
Compilation (snpe-onnx-to-dlc)10-20 minCPU
AI Hub compile job15-30 minCloud
AI Hub profile job5-10 minCloud + Device
Accuracy validation5-15 min1× 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:

  1. Detects which layers are causing the regression
  2. Automatically selects a recovery strategy (mixed precision, ADAROUND, QAT)
  3. Applies the recovery with minimal human intervention
  4. 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 GapOutliersGPU Budget→ Strategy
< 1%Mixed precision only
1-2%Yes2hSmoothQuant + mixed precision
1-2%No2hADAROUND + mixed precision
2-4%Yes20hSmoothQuant + ADAROUND
> 4%50hQAT
Any0hFP16 (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 .pt files
  • 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

ScaleModels/weekBenchmarksGPU-hours/weekGPUs needed
Small20105.64
Medium100308315
Large2006033350
XL5001001,389100+

Scaling strategies:

  1. Horizontal: Add more GPU workers (Ray auto-scales)
  2. Model sharing: Load once, run all benchmarks → amortize load time
  3. Parallelism: 60 benchmarks × 50 workers = 1 benchmark per worker simultaneously
  4. Early termination: If model is clearly worse (first 100 examples), skip rest
  5. Tiered evaluation: Fast tier (100 examples, 3 benchmarks) → full eval if passes

Fault Tolerance

FailureDetectionRecovery
Worker GPU OOMCUDA OOM exceptionRetry with smaller batch; fallback to CPU
S3 download failureHTTP errorRetry 3× with exponential backoff
Model loading corruptiontorch.load exceptionRedownload from S3; invalidate local cache
DB write failureDB connection errorWrite to local queue; replay later
Airflow scheduler failureWatchdog processRestart Airflow; incomplete jobs restart cleanly
Ray head node failureHealth checkRedeploy 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

BackendUse Case
PyTorch eagerBaseline
torch.compile(backend="inductor")GPU optimization
torch.compile(backend="trt")TensorRT
ONNX Runtime (CUDA)Cross-framework
TensorRT directlyMax 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:

  1. Pinned environment: Docker image with pinned torch==2.3.0+cu121, triton==2.3.0, driver version
  2. Fixed hardware state: GPU boost clocks locked (nvidia-smi -lgc 1410,1410 for A100), no concurrent workloads
  3. Fixed input: deterministic input generation (torch.manual_seed(42))
  4. Warmup protocol: standardized warmup (20 iterations before measurement)
  5. 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:

  1. Latency over time: p50/p95/p99 per (model, hardware, backend) — detect regressions
  2. Throughput vs. batch size: shows batching efficiency curve
  3. Efficiency radar chart: 5 dimensions — throughput, memory, latency, accuracy, power
  4. Pareto frontier: accuracy vs. latency/throughput for all model variants
  5. Hardware comparison: same model across A100, H100, Snap 8 Gen 3

SLA definitions:

TierLatency SLOUse Case
Realtimep99 < 100msInteractive chat, autocomplete
Batchp99 < 1sAsync API, document processing
Edgep99 < 50msOn-device, offline
Throughput-optimized> 1000 tok/sBulk inference