Research Scientist
Efficient ML & Model Compression
As models scale to hundreds of billions of parameters, efficient inference and training becomes a first-class research problem. Efficient ML encompasses knowledge distillation (training small models to mimic large ones), pruning (removing redundant weights), quantization (reducing numerical precision), and parameter-efficient fine-tuning (LoRA, adapters). Research Scientists at Anthropic, Google DeepMind, and Meta FAIR must understand both the algorithms and the hardware constraints — roofline model, memory bandwidth bottlenecks, and why Flash Attention's speedup comes from memory access patterns rather than FLOP reduction.
Core Theory
- 1Knowledge distillation — soft targets and temperature: train a small 'student' network to mimic a large 'teacher'. Hinton et al. 2015: instead of training on hard labels (one-hot), train on the teacher's softmax outputs ('soft targets'). Loss: L = α × L_CE(student, hard_labels) + (1-α) × T² × L_KL(student_T, teacher_T), where student_T = softmax(z_s/T), teacher_T = softmax(z_t/T), T > 1 is temperature. The T² factor compensates for the reduced gradient magnitude from soft targets. Why soft targets help: the teacher's distribution encodes 'dark knowledge' — e.g., P(cat)=0.01 for a dog image tells the student that cats are the most plausible mistake. At T=1 this signal is tiny; T=3-10 amplifies it. The student achieves near-teacher performance with 10-100× fewer parameters.
- 2Intermediate feature distillation and Born-Again Networks: beyond soft targets, distill intermediate representations. FitNets: student's hidden features should match teacher's via a regressor layer. AT (Attention Transfer): student's spatial attention maps should match teacher's. PKD (Patient Knowledge Distillation): match all transformer layers, not just the final one — patient KD is the technique used in DistilBERT. Born-Again Networks: train student with same architecture as teacher — surprisingly improves performance beyond a single model. The student has access to the teacher's exact posterior, which acts as a smooth, less-overfit training signal. Ensembling multiple generations of BANs further improves performance.
- 3Pruning — unstructured vs. structured: weight pruning removes individual weights (sparse tensors). Magnitude pruning: remove weights where |w| < threshold. Problem: unstructured sparsity does NOT speed up inference on standard hardware (GPUs compute dense matrix multiplications). Structured pruning removes entire neurons, attention heads, or layers → actual speedup. Lottery Ticket Hypothesis (Frankle & Carlin 2019): a random initialization contains a 'winning ticket' — a sparse subnetwork that, when trained from the same initialization (not retrained from random), matches the full network's accuracy. Finding the ticket: (1) train full network; (2) prune the p% smallest magnitude weights; (3) rewind weights to original initialization (not zero!); (4) retrain sparse network. Critically, reinitializing to random (not original init) fails — the init matters.
- 4Iterative magnitude pruning (IMP): pruning then retraining is more effective than pruning all at once. Algorithm: (1) train full network; (2) prune 20% of remaining weights by magnitude; (3) retrain/fine-tune; (4) repeat steps 2-3 until target sparsity. With IMP, models can reach 90%+ sparsity with minimal accuracy loss (lottery tickets exist at all scales). Practical issue: 90% sparse models on GPU are not faster without special sparse CUDA kernels. Hardware acceleration for sparsity: NVIDIA Ampere's 2:4 structured sparsity (exactly 2 non-zeros per 4-element group) gives 2× theoretical speedup and is hardware-accelerated.
- 5Post-training quantization (PTQ): convert a trained float32 model to int8 or int4 without retraining. Uniform affine quantization: Q(x) = round(x/s) - z where scale s = (x_max - x_min)/(2^b - 1), zero-point z corrects for asymmetric ranges. Quantization error: MSE(x, dequant(Q(x))) ≈ s²/12 (uniform quantization error is approximately uniform in [0, s²/12]). Problem: weight distributions in transformers have outlier channels with very large magnitudes → small s (to cover outliers) → all other weights map to same integer → catastrophic accuracy loss. LLM.int8() (Dettmers et al. 2022): decompose matrix multiplication into two parts — (1) outlier channels (columns with large magnitude) computed in float16, (2) remaining channels quantized to int8. Combines both results. This 2-step decomposition enables 8-bit inference for 175B+ parameter models.
- 6GPTQ — one-shot quantization for LLMs: uses the Optimal Brain Compression (OBC) framework. For each row of weight matrix W, minimize quantization error: argmin_{Q} ||W X - Q X||_F² where X is calibration data. Solves the optimal quantization order using the Hessian H = 2XX^T. GPTQ: quantize weights in order of Hessian-weighted sensitivity, updating remaining unquantized weights to compensate (lazy batch update). Achieves INT4 quantization with minimal accuracy loss on GPT models. Key insight: instead of independently quantizing each weight (which ignores correlations), GPTQ updates remaining weights to correct for quantization error — a second-order method.
- 7LoRA — Low-Rank Adaptation: for a pre-trained weight matrix W_0 ∈ R^{d×k}, constrain the update to be low-rank: W = W_0 + ΔW = W_0 + BA where B ∈ R^{d×r}, A ∈ R^{r×k}, r << min(d,k). Only A and B are trained; W_0 is frozen. Initialization: A ~ N(0, σ²), B = 0 → ΔW = 0 at init (no change to model behavior). FLOPs during forward: full W_0 forward + BA forward. No extra FLOPs at inference if merged: W = W_0 + BA (merge before deployment, zero inference overhead). Rank selection: r=4,8,16 covers most use cases. Theoretical justification: Aghajanyan et al. 2021 showed pre-trained models have very low intrinsic dimensionality — fine-tuning tasks live in a much lower-dimensional subspace than the full parameter space.
- 8QLoRA — quantized LoRA: combine 4-bit quantization (NF4 — Normal Float 4, optimal for normally distributed weights) with LoRA. W_0 stored in NF4 (4-bit); during forward pass, dequantize to BF16 for computation; LoRA adapters A, B stored in BF16. Key innovation: double quantization — quantize the quantization constants themselves (saves ~0.4 bits per parameter). Paged optimizer: use NVIDIA's unified memory to page optimizer states to CPU RAM when GPU memory is full, enabling fine-tuning 65B parameter models on a single A100 80GB. QLoRA enables fine-tuning LLaMA-65B on one GPU — previously requiring 8× A100s.
- 9Flash Attention — IO-aware exact attention: standard attention materializes the full n×n attention matrix in HBM (GPU DRAM), requiring O(n²) memory read/write operations. HBM bandwidth is the bottleneck (not FLOP throughput). Flash Attention (Dao et al. 2022): tile the Q, K, V matrices into blocks that fit in SRAM (GPU L1 cache). Compute attention block by block, using the online softmax algorithm (Milakov & Gimelshein 2018) to correctly normalize without seeing the full row. Never materialize the full n×n matrix in HBM — only write the final O output. Memory: O(n) instead of O(n²). Speed: 2-4× faster on A100 for typical sequence lengths (1K-8K). Asymptotically same FLOP count as standard attention — speedup is purely from fewer HBM read/writes. Flash Attention v2/v3 adds parallelism and further reduces non-matmul FLOPs.
- 10Speculative decoding: LLM inference is memory-bandwidth bound (loading 70B parameters every token). Idea: use a small 'draft' model (7B) to generate k=4-8 draft tokens quickly, then verify all k+1 tokens in parallel with the large model (using a single batched forward pass). Accept each draft token if P_large(token | context) / P_draft(token | context) > uniform random → guarantees the large model's token distribution is preserved exactly (rejection sampling). Average acceptance rate: 60-80% for good draft/target model pairs. Speedup: 2-3× on typical generation tasks (coding, conversation). Key insight: the large model does k+1 tokens in 1 forward pass cost (batched verification) instead of k+1 sequential passes. Works because language is locally predictable — draft model gets most tokens right.
Key Formulas
| 1 | import torch |
| 2 | import torch.nn as nn |
| 3 | import torch.nn.functional as F |
| 4 | import math |
| 5 | from typing import Optional |
| 6 | |
| 7 | # ─── LoRA Layer ──────────────────────────────────────────────────────────────── |
| 8 | |
| 9 | class LoRALinear(nn.Module): |
| 10 | """ |
| 11 | LoRA wrapper around a Linear layer. |
| 12 | W = W_0 + (alpha/r) * B @ A |
| 13 | W_0 is frozen; only A and B are trained. |
| 14 | |
| 15 | At inference, merge: W_merged = W_0 + (alpha/r) * B @ A |
| 16 | (zero extra FLOPs vs original linear) |
| 17 | |
| 18 | Parameters |
| 19 | ---------- |
| 20 | base_layer : nn.Linear — the frozen pre-trained layer |
| 21 | r : int — LoRA rank (typical: 4, 8, 16, 32) |
| 22 | alpha : float — LoRA scaling factor (often = r) |
| 23 | dropout : float — applied before LoRA matrices |
| 24 | """ |
| 25 | def __init__(self, base_layer: nn.Linear, r: int = 8, alpha: float = 16.0, |
| 26 | dropout: float = 0.0): |
| 27 | super().__init__() |
| 28 | self.base_layer = base_layer |
| 29 | self.r = r |
| 30 | self.alpha = alpha |
| 31 | self.scaling = alpha / r |
| 32 | |
| 33 | d_out, d_in = base_layer.weight.shape |
| 34 | |
| 35 | # Freeze base weights |
| 36 | base_layer.weight.requires_grad_(False) |
| 37 | if base_layer.bias is not None: |
| 38 | base_layer.bias.requires_grad_(False) |
| 39 | |
| 40 | # LoRA matrices: A (random Gaussian init), B (zero init) |
| 41 | # B=0 ensures ΔW = BA = 0 at the start of training |
| 42 | self.lora_A = nn.Parameter(torch.empty(r, d_in)) |
| 43 | self.lora_B = nn.Parameter(torch.zeros(d_out, r)) |
| 44 | self.lora_dropout = nn.Dropout(p=dropout) |
| 45 | |
| 46 | # Initialize A with Kaiming uniform (standard LoRA) |
| 47 | nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5)) |
| 48 | |
| 49 | # Track merged state |
| 50 | self._merged = False |
| 51 | |
| 52 | def forward(self, x: torch.Tensor) -> torch.Tensor: |
| 53 | if self._merged: |
| 54 | # After merge: single forward pass, no overhead |
| 55 | return self.base_layer(x) |
| 56 | else: |
| 57 | # Base output (frozen) |
| 58 | base_out = self.base_layer(x) |
| 59 | # LoRA delta: x → dropout → A → B → scale |
| 60 | lora_out = (self.lora_dropout(x) @ self.lora_A.T) @ self.lora_B.T |
| 61 | return base_out + self.scaling * lora_out |
| 62 | |
| 63 | def merge_weights(self): |
| 64 | """ |
| 65 | Merge LoRA into base weights for zero-overhead inference. |
| 66 | W_merged = W_0 + (alpha/r) * B @ A |
| 67 | """ |
| 68 | if not self._merged: |
| 69 | delta_W = self.scaling * (self.lora_B @ self.lora_A) |
| 70 | self.base_layer.weight.data += delta_W |
| 71 | self._merged = True |
| 72 | print(f"Merged LoRA: {delta_W.abs().mean():.6f} mean delta magnitude") |
| 73 | |
| 74 | def unmerge_weights(self): |
| 75 | if self._merged: |
| 76 | delta_W = self.scaling * (self.lora_B @ self.lora_A) |
| 77 | self.base_layer.weight.data -= delta_W |
| 78 | self._merged = False |
| 79 | |
| 80 | @property |
| 81 | def trainable_params(self) -> int: |
| 82 | return self.lora_A.numel() + self.lora_B.numel() |
| 83 | |
| 84 | @property |
| 85 | def total_params(self) -> int: |
| 86 | return self.base_layer.weight.numel() |
| 87 | |
| 88 | @property |
| 89 | def compression_ratio(self) -> float: |
| 90 | return self.total_params / self.trainable_params |
| 91 | |
| 92 | |
| 93 | # ─── Knowledge Distillation Loss ────────────────────────────────────────────── |
| 94 | |
| 95 | class KnowledgeDistillationLoss(nn.Module): |
| 96 | """ |
| 97 | Hinton et al. 2015 distillation loss. |
| 98 | |
| 99 | L = alpha * CE(student_logits, hard_labels) |
| 100 | + (1 - alpha) * T^2 * KL(student_soft || teacher_soft) |
| 101 | |
| 102 | The T^2 factor compensates for the (1/T) scale reduction |
| 103 | in soft probabilities: gradient magnitudes are T^2 smaller |
| 104 | at temperature T compared to T=1. |
| 105 | |
| 106 | Parameters |
| 107 | ---------- |
| 108 | temperature : float — > 1 softens distributions, amplifying dark knowledge |
| 109 | alpha : float — weight on hard label loss (0 = pure distillation) |
| 110 | """ |
| 111 | def __init__(self, temperature: float = 4.0, alpha: float = 0.5): |
| 112 | super().__init__() |
| 113 | self.T = temperature |
| 114 | self.alpha = alpha |
| 115 | |
| 116 | def forward(self, |
| 117 | student_logits: torch.Tensor, |
| 118 | teacher_logits: torch.Tensor, |
| 119 | hard_labels: Optional[torch.Tensor] = None) -> dict: |
| 120 | """ |
| 121 | student_logits: (B, C) — raw logits from student |
| 122 | teacher_logits: (B, C) — raw logits from teacher (detached, no grad) |
| 123 | hard_labels: (B,) — ground truth class indices (optional) |
| 124 | """ |
| 125 | # Soft targets: apply temperature to both student and teacher |
| 126 | student_soft = F.log_softmax(student_logits / self.T, dim=-1) |
| 127 | teacher_soft = F.softmax(teacher_logits / self.T, dim=-1) |
| 128 | |
| 129 | # KL divergence: KL(teacher || student) = sum(teacher * (log_teacher - log_student)) |
| 130 | # Using F.kl_div(input=log_student, target=teacher) — PyTorch convention |
| 131 | soft_loss = F.kl_div(student_soft, teacher_soft, reduction='batchmean') |
| 132 | # Multiply by T^2 to compensate for 1/T^2 scale reduction in soft targets |
| 133 | soft_loss = soft_loss * (self.T ** 2) |
| 134 | |
| 135 | total_loss = (1 - self.alpha) * soft_loss |
| 136 | |
| 137 | hard_loss = torch.tensor(0.0) |
| 138 | if hard_labels is not None and self.alpha > 0: |
| 139 | hard_loss = F.cross_entropy(student_logits, hard_labels) |
| 140 | total_loss = total_loss + self.alpha * hard_loss |
| 141 | |
| 142 | return { |
| 143 | "total": total_loss, |
| 144 | "soft_loss": soft_loss.item(), |
| 145 | "hard_loss": hard_loss.item() if isinstance(hard_loss, torch.Tensor) else hard_loss, |
| 146 | } |
| 147 | |
| 148 | |
| 149 | # ─── Simple INT8 Quantization Simulation ────────────────────────────────────── |
| 150 | |
| 151 | def quantize_to_int8(tensor: torch.Tensor, per_channel: bool = False): |
| 152 | """ |
| 153 | Simulate INT8 symmetric quantization. |
| 154 | scale = max(|x|) / 127 |
| 155 | Q(x) = round(x / scale).clamp(-128, 127) |
| 156 | Dequantize: x_hat = Q(x) * scale |
| 157 | |
| 158 | per_channel: compute separate scale per output channel (more accurate) |
| 159 | """ |
| 160 | if per_channel and tensor.dim() >= 2: |
| 161 | # Scale per output row (per output channel for weight matrices) |
| 162 | max_vals = tensor.abs().amax(dim=tuple(range(1, tensor.dim())), keepdim=True) |
| 163 | else: |
| 164 | max_vals = tensor.abs().max() |
| 165 | |
| 166 | scale = max_vals / 127.0 |
| 167 | scale = scale.clamp(min=1e-8) # avoid division by zero |
| 168 | |
| 169 | # Quantize |
| 170 | q = (tensor / scale).round().clamp(-128, 127).to(torch.int8) |
| 171 | # Dequantize for simulation (convert back to float) |
| 172 | x_hat = q.float() * scale |
| 173 | |
| 174 | # Quantization error |
| 175 | error = (tensor - x_hat).abs().mean().item() |
| 176 | snr = (tensor.var() / (tensor - x_hat).var().clamp(min=1e-12)).item() |
| 177 | |
| 178 | return x_hat, scale, {"mae": error, "snr_db": 10 * math.log10(max(snr, 1e-12))} |
| 179 | |
| 180 | |
| 181 | # ─── Demo ────────────────────────────────────────────────────────────────────── |
| 182 | |
| 183 | if __name__ == "__main__": |
| 184 | torch.manual_seed(42) |
| 185 | |
| 186 | # ── LoRA Demo ────────────────────────────────────────────────────────────── |
| 187 | base = nn.Linear(4096, 4096, bias=False) |
| 188 | lora = LoRALinear(base, r=8, alpha=16.0) |
| 189 | |
| 190 | total = lora.total_params |
| 191 | trainable = lora.trainable_params |
| 192 | print(f"Base params: {total:,} | LoRA params: {trainable:,} | " |
| 193 | f"Ratio: {lora.compression_ratio:.0f}x fewer trainable params") |
| 194 | # Base params: 16,777,216 | LoRA params: 65,536 | Ratio: 256x fewer |
| 195 | |
| 196 | x = torch.randn(2, 128, 4096) # (batch, seq, d_model) |
| 197 | out_before = lora(x) |
| 198 | lora.merge_weights() |
| 199 | out_after = lora(x) |
| 200 | print(f"Max diff after merge: {(out_before - out_after).abs().max():.2e}") # ~1e-5 |
| 201 | |
| 202 | # ── Distillation Loss Demo ────────────────────────────────────────────────── |
| 203 | teacher_logits = torch.randn(16, 100) # teacher: 16 samples, 100 classes |
| 204 | student_logits = torch.randn(16, 100) # student (smaller model) |
| 205 | labels = torch.randint(0, 100, (16,)) |
| 206 | |
| 207 | kd_loss = KnowledgeDistillationLoss(temperature=4.0, alpha=0.5) |
| 208 | losses = kd_loss(student_logits, teacher_logits.detach(), labels) |
| 209 | print(f"KD total loss: {losses['total']:.4f}, " |
| 210 | f"soft: {losses['soft_loss']:.4f}, hard: {losses['hard_loss']:.4f}") |
| 211 | |
| 212 | # ── INT8 Quantization Demo ───────────────────────────────────────────────── |
| 213 | W = torch.randn(4096, 4096) # Typical transformer weight matrix |
| 214 | |
| 215 | W_q_global, scale_g, stats_g = quantize_to_int8(W, per_channel=False) |
| 216 | W_q_channel, scale_c, stats_c = quantize_to_int8(W, per_channel=True) |
| 217 | |
| 218 | print(f"Global INT8 — MAE: {stats_g['mae']:.6f}, SNR: {stats_g['snr_db']:.1f} dB") |
| 219 | print(f"Per-channel INT8 — MAE: {stats_c['mae']:.6f}, SNR: {stats_c['snr_db']:.1f} dB") |
| 220 | # Per-channel always achieves better SNR (especially with outlier channels) |
| 221 | |
| 222 | # Simulate outlier effect: one channel with 100x larger magnitude |
| 223 | W_outlier = W.clone() |
| 224 | W_outlier[0, :] *= 100.0 |
| 225 | _, _, stats_outlier = quantize_to_int8(W_outlier, per_channel=False) |
| 226 | _, _, stats_outlier_pc = quantize_to_int8(W_outlier, per_channel=True) |
| 227 | print(f"With outliers — global SNR: {stats_outlier['snr_db']:.1f} dB, " |
| 228 | f"per-channel SNR: {stats_outlier_pc['snr_db']:.1f} dB") |
| 229 | # Shows dramatic SNR drop for global quantization with outliers |
Three implementations: (1) LoRALinear wraps any nn.Linear, freezes base weights, adds rank-r adapters A and B — B initialized to zero so ΔW=0 at training start, and includes merge_weights() to absorb LoRA into W_0 for zero-overhead inference; (2) KnowledgeDistillationLoss implements the T² compensation factor correctly — F.kl_div expects log probabilities as input (log_softmax, not softmax) following PyTorch conventions; (3) INT8 quantization simulation demonstrates why per-channel quantization dramatically outperforms global when outlier channels exist — this is the core insight behind LLM.int8() and SmoothQuant.
Worked Interview Problems
3 problemsProblem
A transformer block has query/key/value/output projections each of shape (4096, 4096). Calculate: (a) total parameters in these 4 matrices, (b) LoRA parameters with r=8, (c) compression ratio. Then explain why low-rank updates are sufficient for fine-tuning.
Solution
Total parameters per matrix: d × k = 4096 × 4096 = 16,777,216 ≈ 16.8M. For 4 matrices: 4 × 16.8M = 67.1M parameters per layer.
LoRA parameters per matrix: B ∈ R^{4096×8} (32,768) + A ∈ R^{8×4096} (32,768) = 65,536 parameters. For 4 matrices: 4 × 65,536 = 262,144 ≈ 262K per layer.
Compression ratio per layer: 67.1M / 262K = 256× fewer trainable parameters. For a 32-layer transformer: 32 × 262K = 8.4M LoRA params vs. 32 × 67.1M = 2.15B base params — LoRA trains only 0.4% of parameters.
Why does r=8 work? Intrinsic dimensionality hypothesis (Aghajanyan et al. 2021): pre-trained representations have very low intrinsic dimensionality. Fine-tuning moves the model to a nearby point in parameter space along a low-dimensional manifold. For NLP tasks, the fine-tuning gradient ∂L/∂W often has rank << min(d,k) — most variation is in a small subspace.
Empirical verification: LoRA paper ablation shows diminishing returns beyond r=8 for most tasks. For r=1,2,4,8,16,64: GLUE performance plateaus at r=8-16. Very small r (1-2) is sufficient for simple tasks. Very large r approaches full fine-tuning performance but loses efficiency.
Intuition: fine-tuning a 7B model on a specific task (e.g., code generation) requires 'turning on' certain capabilities already latent in the model. This activation lives in a low-dimensional direction in weight space. You don't need to change all 4096 dimensions of each weight matrix — changing 8-16 directions is sufficient to unlock the capability.
Answer
Total params: 4 × 4096² = 67.1M per layer. LoRA (r=8): 4 × (4096×8 + 8×4096) = 262K — 256× compression. For a 32-layer 7B model, LoRA trains ~8M of 7B params (0.1%). This works because fine-tuning tasks have low intrinsic dimensionality: the update ΔW lies in a low-dimensional subspace of R^{d×k}, well-approximated by rank-8 decomposition.
Common Mistakes
Claiming unstructured pruning speeds up inference: removing individual weights creates sparse tensors. Standard GPU matrix multiplication (GEMM) kernels require dense matrices — sparse operations are not automatically faster. Unstructured 90% sparsity may actually be slower on GPU than dense computation due to irregular memory access. Only structured pruning (removing heads, channels, layers) or hardware-supported sparsity formats (e.g., NVIDIA 2:4) give real speedups.
Forgetting the T² factor in knowledge distillation loss: Hinton et al.'s original formulation multiplies the KL term by T². Without it, the soft loss gradient is T² smaller than the hard label loss gradient, effectively making the hard labels dominate regardless of the alpha weighting. Many re-implementations get this wrong. In PyTorch: F.kl_div(F.log_softmax(s/T), F.softmax(t/T)) × T².
Confusing LoRA rank r with quality: r is not simply 'higher is better'. Beyond r=16, performance gains plateau for most NLP fine-tuning tasks (LoRA paper ablation, Table 9). Very large r (r=d_in/2) approaches full fine-tuning in both quality and computational cost, eliminating LoRA's advantage. Optimal r is task-dependent: mathematical reasoning benefits from r=16-64; simple style transfer needs only r=4.
Thinking quantization error is uniform across layers: layers near the input and output of transformers are more sensitive to quantization than middle layers. The first and last attention projection layers often need higher precision (int8 or fp16) while middle layers tolerate int4. Sensitivity analysis (measure perplexity change when quantizing each layer independently) should inform mixed-precision strategies like GPTQ's layer-by-layer approach.
Not understanding why speculative decoding doesn't change the output distribution: the acceptance criterion P_large(token)/P_draft(token) compared to uniform random is a rejection sampling procedure that provably samples from P_large. If a draft token is rejected, a corrected token is sampled from a renormalized P_large. The output is mathematically identical in distribution to sequential sampling from P_large — speculative decoding is a lossless acceleration technique.