Home/Machine Learning/Efficient ML & Model Compression
Back

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.

Deep learning training (backprop, optimizers)Linear algebra (SVD, low-rank decomposition)Floating point arithmetic basicsBasic GPU architecture (DRAM, SRAM, FLOPS)QuantizationPruningLoRAKnowledge DistillationFlash AttentionSpeculative DecodingGPTQQLoRA

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

01Ldistill=αLCE(y,ps)+(1α)T2LKL(ps(T),pt(T))\mathcal{L}_{\text{distill}} = \alpha \mathcal{L}_{\text{CE}}(y, p_s) + (1-\alpha) T^2 \mathcal{L}_{\text{KL}}(p_s^{(T)}, p_t^{(T)})
02LoRA: W=W0+BA,BRd×r,  ARr×k,  rmin(d,k)\text{LoRA: } W = W_0 + BA, \quad B \in \mathbb{R}^{d \times r},\; A \in \mathbb{R}^{r \times k},\; r \ll \min(d,k)
03Quantization: Q(x)=round(x/s)z,s=(xmaxxmin)/(2b1)\text{Quantization: } Q(x) = \text{round}(x / s) - z, \quad s = (x_{\max} - x_{\min}) / (2^b - 1)
04Quantization MSEs2/12=(xmaxxmin)2/(12(2b1)2)\text{Quantization MSE} \approx s^2 / 12 = (x_{\max} - x_{\min})^2 / (12 \cdot (2^b - 1)^2)
05LoRA param savings: r(d+k)dk,e.g., d=k=4096,r=8:6553616.7M\text{LoRA param savings: } r(d+k) \ll dk, \quad \text{e.g., } d=k=4096, r=8: 65536 \ll 16.7\text{M}
06Roofline model: attainable FLOPS=min(peak FLOPS,I×peak BW)\text{Roofline model: } \text{attainable FLOPS} = \min(\text{peak FLOPS},\, I \times \text{peak BW})
07Arithmetic intensity: I=FLOPs/bytes accessed\text{Arithmetic intensity: } I = \text{FLOPs} / \text{bytes accessed}
python
1import torch
2import torch.nn as nn
3import torch.nn.functional as F
4import math
5from typing import Optional
6
7# ─── LoRA Layer ────────────────────────────────────────────────────────────────
8
9class 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
95class 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
151def 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
183if __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 problems

Problem

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

1

Total parameters per matrix: d × k = 4096 × 4096 = 16,777,216 ≈ 16.8M. For 4 matrices: 4 × 16.8M = 67.1M parameters per layer.

2

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.

3

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.

4

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.

5

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.

6

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.

Only works in the Electron app

<webview> is an Electron-only tag. Run npm run electron:dev to use this.

25:00Focus
0

25 min focus · 5 min break · long break every 4 sessions

The Ultimate Grind