Mini PyTorch — E2E Architecture Reference
Projects: Autograd Engine · Matrix Multiplication Kernel · Softmax Kernel · Transformer from Scratch · Flash Attention Kernel · Vision Transformer · Diffusion Model (UNet + Scheduler)
What You're Building
A ground-up implementation of the compute stack that trains neural networks: automatic differentiation, hand-written GPU kernels, and the core architectures that define modern deep learning. After completing this system you will understand exactly what loss.backward() does, why Flash Attention exists, and how images can be generated from pure noise.
Project 1 — Autograd Engine (like Micrograd)
Conceptual Architecture
The autograd engine is a lazy computation graph where every operation records how to undo itself. Forward pass builds the graph; backward pass walks it in reverse to propagate gradients.
FORWARD PASS
────────────
User code: Graph built behind the scenes:
x = Value(2.0) x (data=2.0, grad=0)
w = Value(-3.0) w (data=-3.0, grad=0)
b = Value(5.0) b (data=5.0, grad=0)
n = x*w + b │
loss = n.tanh() n=x*w+b → loss=tanh(n)
BACKWARD PASS
─────────────
loss.backward()
Step 1: Topological sort (DFS, reverse postorder)
[x, w, b, x*w, n, loss] ← evaluation order
reversed = [loss, n, x*w, b, w, x]
Step 2: Set loss.grad = 1.0
Step 3: For each node in reverse topo order:
call node._backward() ← closure set during forward
Example _backward for multiplication (n = x * w):
x.grad += w.data * n.grad
w.grad += x.data * n.grad
Data Structure
class Value:
def __init__(self, data):
self.data = float(data) # actual value
self.grad = 0.0 # ∂loss/∂self, accumulates
self._backward = lambda: None # filled in by ops
self._prev = set() # parent nodes (for graph)
def __mul__(self, other):
out = Value(self.data * other.data, (self, other))
def _backward():
self.grad += other.data * out.grad # chain rule
other.grad += self.data * out.grad
out._backward = _backward
return out
def backward(self):
topo = []
visited = set()
def build(v):
if v not in visited:
visited.add(v)
for child in v._prev: build(child)
topo.append(v)
build(self)
self.grad = 1.0
for node in reversed(topo):
node._backward()
Operations to Implement
| Op | Forward | Backward |
|---|---|---|
| add | a + b |
da += dout, db += dout |
| mul | a * b |
da += b*dout, db += a*dout |
| pow | a^k |
da += k*a^(k-1)*dout |
| relu | max(0, a) |
da += (a>0)*dout |
| tanh | tanh(a) |
da += (1-tanh²(a))*dout |
| exp | e^a |
da += e^a * dout |
| log | ln(a) |
da += (1/a)*dout |
Training Loop
for epoch in range(epochs):
# Forward
ypred = [model(x) for x in X_train]
loss = mean_squared_error(ypred, Y_train)
# Zero gradients (must reset each step — grads accumulate)
for p in model.parameters():
p.grad = 0.0
# Backward
loss.backward()
# Update (SGD)
for p in model.parameters():
p.data -= lr * p.grad
Extension to Tensors
Replace float with np.ndarray. The _backward closures become vectorized operations. Most ops remain identical; broadcasting rules must be handled by summing gradients along broadcasted dims.
Common Gotchas
- Gradient accumulation:
grad +=notgrad =— same node can receive grad from multiple paths - In-place ops: Break the graph; avoid or clone before modifying
- Non-leaf grads: Usually don't need to be stored (set
requires_grad=Falsefor efficiency) - Numerical check: Finite difference verification —
(f(x+h) - f(x-h)) / 2h ≈ grad
Project 2 — Matrix Multiplication Kernel (CUDA)
Problem Statement
Compute C = A @ B where A is (M,K), B is (K,N), C is (M,N). This is the dominant operation in transformer inference and training.
Level 1 — Naive Kernel
Grid: dim3(ceil(N/32), ceil(M/32)) ← 2D grid of thread blocks
Block: dim3(32, 32) ← 1024 threads per block
Each thread (row, col) computes one output element:
float acc = 0;
for (int k = 0; k < K; k++)
acc += A[row*K + k] * B[k*N + col];
C[row*N + col] = acc;
Problem: Each output element reads K values from GLOBAL memory (HBM)
HBM bandwidth ≈ 2 TB/s, latency ≈ 400 cycles
Compute intensity too low → memory bound
Level 2 — Shared Memory Tiling
TILE = 32
Each block is responsible for a (TILE × TILE) output tile
__shared__ float As[TILE][TILE];
__shared__ float Bs[TILE][TILE];
float acc = 0.0;
for (int t = 0; t < K/TILE; t++) {
// Cooperatively load tile of A and B into SRAM
As[ty][tx] = A[row * K + (t*TILE + tx)];
Bs[ty][tx] = B[(t*TILE + ty) * N + col];
__syncthreads();
// Compute on fast shared memory
for (int k = 0; k < TILE; k++)
acc += As[ty][k] * Bs[k][tx];
__syncthreads();
}
C[row*N + col] = acc;
HBM reads reduced: O(M*N*K) → O(M*N*K / TILE)
SRAM bandwidth: ~19 TB/s (10x faster than HBM)
Level 3 — Register Blocking + Vectorized Loads
Each thread owns a (Rv × Rc) = (8 × 8) output tile in REGISTERS
Threads per block: TILE/Rv × TILE/Rc
Load with float4 (128-bit):
float4 a4 = *reinterpret_cast<float4*>(&A[...]);
→ 4 floats in 1 memory transaction (4x bandwidth utilization)
Double buffering:
While computing tile[t], prefetch tile[t+1] into second shared mem buffer
Hides memory latency with compute
Achieved performance: ~60-70% of peak FLOPS
Level 4 — Tensor Cores (Warp Matrix Multiply)
Uses CUDA's wmma (Warp Matrix Multiply Accumulate) API
Operates on 16×16×16 matrix fragments in FP16
wmma::fragment<wmma::matrix_a, 16,16,16, half> a_frag;
wmma::fragment<wmma::matrix_b, 16,16,16, half> b_frag;
wmma::fragment<wmma::accumulator, 16,16,16, float> c_frag;
wmma::fill_fragment(c_frag, 0.0f);
wmma::load_matrix_sync(a_frag, A_ptr, K);
wmma::load_matrix_sync(b_frag, B_ptr, N);
wmma::mma_sync(c_frag, a_frag, b_frag, c_frag); ← single instruction
wmma::store_matrix_sync(C_ptr, c_frag, N, wmma::mem_row_major);
A100 Tensor Core peak: 312 TFLOPS (FP16), 77 TFLOPS (FP32)
cuBLAS achieves ~80% of peak via highly tuned tile sizes + scheduling
Full Pipeline
Host Setup:
cudaMalloc(&d_A, M*K*sizeof(float))
cudaMalloc(&d_B, K*N*sizeof(float))
cudaMalloc(&d_C, M*N*sizeof(float))
cudaMemcpy(d_A, h_A, M*K*sizeof(float), cudaMemcpyHostToDevice)
cudaMemcpy(d_B, h_B, K*N*sizeof(float), cudaMemcpyHostToDevice)
Kernel Launch:
dim3 gridDim(ceil(N/TILE), ceil(M/TILE))
dim3 blockDim(TILE, TILE)
matmul_kernel<<<gridDim, blockDim>>>(d_A, d_B, d_C, M, N, K)
cudaDeviceSynchronize()
Copy Back:
cudaMemcpy(h_C, d_C, M*N*sizeof(float), cudaMemcpyDeviceToHost)
Profiling:
nsys profile ./matmul ← Nsight Systems (timeline)
ncu --metrics ... ./matmul ← Nsight Compute (kernel deep dive)
Check: memory throughput, achieved occupancy, warp efficiency
Project 3 — Softmax Kernel Optimization
The Three-Stage Evolution
STAGE 1: NAIVE (Numerically Unstable)
out[i] = exp(x[i]) / sum_j(exp(x[j]))
Problem: exp(x[i]) → ∞ for x[i] > 88 (FP32 overflow)
STAGE 2: NUMERICALLY STABLE (3-pass)
m = max(x) [pass 1: find max]
s = sum(exp(x[i] - m)) [pass 2: shifted sum]
out[i] = exp(x[i] - m) / s [pass 3: normalize]
Safe because exp(x[i] - m) ≤ exp(0) = 1
STAGE 3: ONLINE SOFTMAX (2-pass → fused)
Maintain running (max, normalizer) in single pass:
m = -∞, d = 0.0
for each x[i]:
m_prev = m
m = max(m, x[i])
d = d * exp(m_prev - m) + exp(x[i] - m)
┗━ rescale old sum when max updates ━━━━━━━┛
out[i] = exp(x[i] - m) / d
Why this works: invariant that d = Σexp(x[j]-m) for j seen so far
CUDA Kernel Design
Each CUDA thread block handles one softmax row (one sequence position)
WARP-LEVEL REDUCTION:
// 32 threads in a warp reduce together
// Find max across warp
for (int mask = 16; mask > 0; mask >>= 1)
val = max(val, __shfl_xor_sync(0xffffffff, val, mask));
// Sum across warp (after subtracting max)
for (int mask = 16; mask > 0; mask >>= 1)
sum += __shfl_xor_sync(0xffffffff, sum, mask);
// Normalize
out[tid] = exp(x[tid] - warp_max) / warp_sum;
MEMORY LAYOUT:
Input: [batch, heads, seq_len, seq_len] (attention scores)
Each thread block: one (b, h, i) row → processes seq_len elements
Vectorized load: float4 → process 4 elements per thread per load
FUSION WITH ATTENTION:
Instead of: MatMul → store → Softmax → store → MatMul
Do: MatMul + Softmax + MatMul in one kernel pass (Flash Attention)
Eliminates 2 HBM round-trips per layer
Performance Targets
| Implementation | Throughput | HBM Reads |
|---|---|---|
| Naive 3-pass | baseline | 3× seq_len |
| Online 2-pass | 1.5× | 2× seq_len |
| Fused warp reduction | 3× | 1× seq_len |
| Flash Attention fused | 10×+ | O(1) per tile |
Project 4 — Transformer from Scratch
Full Architecture
INPUT
token_ids: (B, T) ← B=batch, T=sequence length
EMBEDDING LAYER
tok_emb = Embedding(vocab_size, d_model) → (B, T, d_model)
pos_emb = Embedding(max_seq_len, d_model) → (T, d_model) [learned]
OR
pos_enc = sinusoidal:
PE[pos, 2i] = sin(pos / 10000^(2i/d_model))
PE[pos, 2i+1] = cos(pos / 10000^(2i/d_model))
x = tok_emb + pos_emb → (B, T, d_model)
x = Dropout(x)
TRANSFORMER BLOCK × N_layers
┌───────────────────────────────────────────────────┐
│ [Pre-LayerNorm] │
│ x_norm = LayerNorm(x) │
│ │
│ [Multi-Head Self-Attention] │
│ Q = x_norm @ W_Q (d_model → d_model) │
│ K = x_norm @ W_K │
│ V = x_norm @ W_V │
│ │
│ Reshape: (B, T, d_model) → (B, n_heads, T, d_k) │
│ d_k = d_model / n_heads │
│ │
│ scores = Q @ K^T / sqrt(d_k) → (B, H, T, T) │
│ mask: upper triangle = -∞ (causal/autoregressive)│
│ attn = softmax(scores) → (B, H, T, T) │
│ attn = Dropout(attn) │
│ out = attn @ V → (B, H, T, d_k) │
│ out = reshape + W_O → (B, T, d_model) │
│ │
│ x = x + Dropout(out) ← residual connection │
│ │
│ [FFN] │
│ x_norm2 = LayerNorm(x) │
│ ffn = Linear(d_model, 4*d_model) │
│ ffn = GELU(ffn) │
│ ffn = Linear(4*d_model, d_model) │
│ ffn = Dropout(ffn) │
│ x = x + ffn ← residual connection │
└───────────────────────────────────────────────────┘
FINAL HEAD
x = LayerNorm(x)
logits = Linear(d_model, vocab_size) → (B, T, vocab_size)
LOSS (next-token prediction)
targets = token_ids shifted left by 1
loss = CrossEntropy(logits.view(-1, vocab_size), targets.view(-1))
Hyperparameters (GPT-2 Small Scale)
| Param | Value |
|---|---|
| d_model | 768 |
| n_heads | 12 |
| n_layers | 12 |
| d_ff | 3072 |
| vocab_size | 50257 |
| max_seq_len | 1024 |
| dropout | 0.1 |
Training Loop
model = Transformer(config).to(device)
optimizer = torch.optim.AdamW(
model.parameters(),
lr=3e-4, betas=(0.9, 0.95),
weight_decay=0.1
)
# Cosine LR schedule with linear warmup
scheduler = CosineAnnealingLR(optimizer, T_max=max_steps)
scaler = torch.cuda.amp.GradScaler() # mixed precision
for step, (x, y) in enumerate(dataloader):
with torch.cuda.amp.autocast():
logits, loss = model(x, y)
scaler.scale(loss).backward()
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(optimizer)
scaler.update()
optimizer.zero_grad(set_to_none=True)
scheduler.step()
Inference / Generation
@torch.no_grad()
def generate(model, prompt_ids, max_new_tokens, temperature=1.0, top_k=50):
for _ in range(max_new_tokens):
# Crop context to max_seq_len
x = prompt_ids[:, -max_seq_len:]
logits = model(x)[:, -1, :] # last position only
logits = logits / temperature
# top-k sampling
v, _ = torch.topk(logits, top_k)
logits[logits < v[:, -1:]] = -float('Inf')
probs = F.softmax(logits, dim=-1)
next_tok = torch.multinomial(probs, num_samples=1)
prompt_ids = torch.cat([prompt_ids, next_tok], dim=1)
return prompt_ids
Project 5 — Flash Attention Kernel (CUDA)
The Core Problem
Standard attention: Attention(Q,K,V) = softmax(QK^T/√d) · V
- QK^T has shape (N, N) — quadratic in sequence length
- Must be stored in HBM for softmax then read back
- Memory: O(N²), compute: O(N²d)
- For N=8192, d=128: 8192² × 4 bytes = 256 MB per head per layer
Flash Attention Solution
Insight: Never materialize the full N×N attention matrix. Process it in tiles that fit in SRAM (on-chip, ~19 TB/s bandwidth).
TILING STRATEGY
Split Q into blocks of size Br rows
Split K,V into blocks of size Bc rows
Process each (Qi, Kj, Vj) combination
For each query block Qi:
Initialize: Oi = 0, ℓi = 0, mi = -∞
For each key/value block (Kj, Vj):
Load Qi, Kj, Vj from HBM → SRAM
Sij = Qi @ Kj^T / sqrt(d) ← local attention scores
m̃ij = rowmax(Sij) ← local max
P̃ij = exp(Sij - m̃ij) ← local softmax numerator
ℓ̃ij = rowsum(P̃ij) ← local sum
# Online softmax update (safe across tiles)
mi_new = max(mi, m̃ij)
ℓi_new = exp(mi - mi_new) * ℓi + exp(m̃ij - mi_new) * ℓ̃ij
# Rescale accumulated output and add new contribution
Oi = (exp(mi - mi_new) * ℓi * Oi + exp(m̃ij - mi_new) * P̃ij @ Vj) / ℓi_new
mi, ℓi = mi_new, ℓi_new
Write final Oi → HBM
Store (ℓi, mi) → HBM for backward pass
SRAM Tile Size Calculation
SRAM budget: 48 KB (typical for shared mem per block)
d = head_dim = 128
Tile holds: Qi (Br×d), Kj (Bc×d), Vj (Bc×d) in FP16
Memory: 2 * (Br + 2*Bc) * d bytes
Set Br = Bc = B:
2 * 3B * 128 = 768B bytes ≤ 48,000 bytes
B ≤ 62 → use B = 64 (power of 2)
Backward Pass (Recomputation)
During forward: store only (O, ℓ, m) — NOT the N×N attention matrix
During backward:
Recompute Sij, Pij from stored Q, K, V, (ℓ, m)
Compute dV, dK, dQ using recomputed values
Memory for backward: O(N) instead of O(N²)
Compute overhead: ~33% more FLOPs (recomputation)
Net result: dramatically faster due to I/O reduction
Performance Comparison
Sequence Length N = 2048, d = 64, Batch = 8, Heads = 12:
Standard Attention:
HBM reads: QKV load + attn matrix write/read = 9 * N*d * 2 bytes = 4.7 GB
HBM writes: attn matrix = N²*B*H * 2 bytes = 3.2 GB
Total: ~8 GB of HBM traffic per forward pass
Flash Attention:
HBM reads: QKV load = 3 * N*d*B*H * 2 bytes = 4.7 GB
HBM writes: O = N*d*B*H * 2 bytes = 1.6 GB
Total: ~6.3 GB — but with NO N² term
At N=8192: 10-20x less HBM traffic
Wall-clock speedup: 2-4x on A100 (memory bandwidth bound)
Project 6 — Vision Transformer (ViT)
Architecture
INPUT IMAGE: (B, 3, H, W) = (B, 3, 224, 224)
PATCH EMBEDDING
Divide image into P×P non-overlapping patches
P=16 → (224/16)² = 196 patches per image
Each patch: (3, 16, 16) → flatten → 768-dim vector via linear layer
Implementation: Conv2d(3, d_model, kernel_size=P, stride=P)
→ same as linear projection of flattened patches
Add [CLS] token (learnable) prepended → seq_len = 197
Add positional embeddings (learnable, shape 197×768)
x: (B, 197, 768)
TRANSFORMER ENCODER × 12 layers
Same as language transformer EXCEPT:
- No causal mask (bidirectional attention)
- [CLS] token attends to all patches; patches attend to each other
MSA (Multi-head Self-Attention):
Q, K, V projections (768 → 768)
12 heads, d_k = 64
scores = QK^T / 8.0 (= √64)
attn = softmax(scores) ← no mask
out = attn @ V → project back
MLP block:
GELU activation (not ReLU)
Expansion: 768 → 3072 → 768
CLASSIFICATION HEAD
Take [CLS] token output: (B, 768)
LayerNorm → Linear(768, num_classes)
→ Softmax → class probabilities
FULL FORWARD:
(B,3,224,224) → PatchEmbed → (B,197,768) → TransformerBlocks × 12
→ CLS extract → (B,768) → MLP Head → (B,1000)
Pretraining Strategies
1. SUPERVISED (original ViT paper)
Train directly on ImageNet-21k (14M images) then fine-tune on IN-1k
Requires large data — ViT underperforms CNNs on small datasets
2. MAE (Masked Autoencoders, He et al 2021)
Mask 75% of patches at random
Encoder processes only visible patches (fast — 4× fewer tokens)
Decoder (lighter) reconstructs masked patches in pixel space
Loss: MSE on masked patches only
Masking strategies: random, block, grid
Key insight: image reconstruction is harder than it seems → good features
3. DINO (Self-supervised)
Student network sees augmented view A
Teacher network (EMA of student) sees augmented view B
Minimize cross-entropy between student/teacher CLS token distributions
Teacher never trained directly — only updated via:
θ_teacher = 0.996 * θ_teacher + 0.004 * θ_student
Fine-tuning Protocol
Pretrained ViT-B/16 (ImageNet-21k) → Fine-tune on target dataset
Settings:
lr = 3e-3 (higher than training from scratch)
weight_decay = 0.01
batch_size = 512
Augmentation: RandAugment(num_ops=2, magnitude=9)
Mixup(alpha=0.2), CutMix(alpha=1.0)
Random erase probability = 0.25
Interpolate positional embeddings if changing resolution
(bicubic interpolation of pretrained pos_emb grid)
Project 7 — Diffusion Model (UNet + Scheduler)
High-Level Intuition
Learn to reverse a gradual noising process. Training: corrupt data with noise, predict the noise. Inference: start from pure noise, iteratively denoise.
Forward Process (Fixed — No Learned Parameters)
NOISE SCHEDULE
β₁, β₂, ..., β_T = linear schedule from 1e-4 to 0.02 (T=1000)
αt = 1 - βt
ᾱt = ∏(α₁ · α₂ · ... · αt) ← cumulative product
CLOSED-FORM NOISING (key property — skip to any timestep directly)
q(xt | x₀) = N(√ᾱt · x₀, (1 - ᾱt) · I)
Sample: xt = √ᾱt · x₀ + √(1-ᾱt) · ε, ε ~ N(0,I)
At t=0: xt ≈ x₀ (clean)
At t=T: xt ≈ ε ~ N(0,I) (pure noise)
UNet Architecture
INPUT: (B, C, H, W) = noisy image xt
+ timestep t (encoded as sinusoidal embedding → MLP → vector)
OUTPUT: (B, C, H, W) = predicted noise ε̂
ENCODER (downsampling path)
Resolution: 256 → 128 → 64 → 32 → 16 → 8
At each resolution:
ResBlock × 2
AttentionBlock (at resolutions ≤ 32)
Downsample: stride-2 Conv (or AvgPool + Conv)
ResBlock:
GroupNorm → SiLU → Conv3×3
+ time_emb projection (Linear → SiLU → Linear → add)
GroupNorm → SiLU → Dropout → Conv3×3
+ residual (1×1 conv if channels change)
BOTTLENECK
ResBlock → AttentionBlock → ResBlock
(operates at lowest spatial resolution)
DECODER (upsampling path)
At each resolution:
Upsample: bilinear or ConvTranspose2d
Concatenate skip connection from encoder (same resolution)
ResBlock × 2 (doubled channels due to skip)
AttentionBlock (at resolutions ≤ 32)
OUTPUT
GroupNorm → SiLU → Conv3×3(channels_out)
Output channels = input channels (predict noise ε̂)
CONDITIONING
Time: sinusoidal embed(t) → Linear(256, d_model) → inject via scale+shift in GroupNorm
Class: learned class embedding → add to time embedding
Text: cross-attention in attention blocks (Q from image features, KV from text encoder)
Training
for x0 in dataloader: # clean images (B, C, H, W)
t = torch.randint(0, T, (B,)) # random timestep per sample
ε = torch.randn_like(x0) # random noise
# Noise the image (closed form — no loop needed)
sqrt_alpha_bar = extract(sqrt_alphas_bar, t, x0.shape)
sqrt_one_minus = extract(sqrt_one_minus_alphas_bar, t, x0.shape)
xt = sqrt_alpha_bar * x0 + sqrt_one_minus * ε
# Predict noise
ε_pred = unet(xt, t)
# Simple MSE loss (from DDPM paper)
loss = F.mse_loss(ε_pred, ε)
loss.backward()
optimizer.step()
optimizer.zero_grad()
# EMA of weights for sampling
ema.update(unet)
Inference — DDPM Sampler (1000 steps)
@torch.no_grad()
def sample_ddpm(model, shape):
x = torch.randn(shape) # start from pure noise
for t in reversed(range(T)): # t: 999 → 0
t_batch = torch.full((B,), t)
ε_pred = model(x, t_batch) # predict noise
# Compute x₀ prediction
x0_pred = (x - sqrt_one_minus_bar[t] * ε_pred) / sqrt_bar[t]
x0_pred = x0_pred.clamp(-1, 1) # clip to valid image range
# Posterior mean
mu = posterior_mean_coef1[t] * x0_pred + posterior_mean_coef2[t] * x
if t > 0:
σ = posterior_variance[t] ** 0.5
x = mu + σ * torch.randn_like(x) # add noise (except final step)
else:
x = mu
return x # (B, C, H, W) generated images
Inference — DDIM Sampler (50 steps, deterministic)
@torch.no_grad()
def sample_ddim(model, shape, steps=50, eta=0.0):
# Select subset of timesteps (strided)
timesteps = torch.linspace(T-1, 0, steps).long()
x = torch.randn(shape)
for i, t in enumerate(timesteps):
ε_pred = model(x, t)
# DDIM step (deterministic when eta=0)
x0_pred = (x - sqrt_one_minus_bar[t] * ε_pred) / sqrt_bar[t]
if i < len(timesteps) - 1:
t_prev = timesteps[i+1]
x = sqrt_bar[t_prev] * x0_pred + sqrt_one_minus_bar[t_prev] * ε_pred
# eta > 0 adds stochasticity back (DDIM → DDPM as eta → 1)
else:
x = x0_pred
return x
Latent Diffusion (Stable Diffusion Extension)
VAE ENCODER → latent z (8× smaller) → DIFFUSION IN LATENT SPACE → VAE DECODER → image
Benefit: 64×64 latents instead of 512×512 pixels
UNet processes 64×64 — 64× fewer elements
8× compression ratio in each spatial dimension
Text conditioning:
CLIP/T5 text encoder → text embeddings (77, 768)
Cross-attention in UNet decoder: Q=image_features, KV=text_embeddings
Classifier-Free Guidance:
ε_guided = ε_uncond + w * (ε_cond - ε_uncond)
w=7.5 typical — trades diversity for prompt adherence
How These 7 Projects Form One System
┌─────────────────────────────────────┐
│ MINI PYTORCH │
│ │
Autograd ──▶ │ Gradient computation engine │
MatMul kernel ──▶ │ GPU compute primitive │
Softmax kernel ─▶ │ Numerical stability + fusion │
Transformer ──▶ │ Core architecture │
Flash Attention ▶ │ Memory-efficient attention │
ViT ──────────▶ │ Extends to vision │
Diffusion ────▶ │ Extends to generation │
└─────────────────────────────────────┘
Dependency graph:
Autograd
└─▶ Transformer
├─▶ Flash Attention (makes Transformer fast)
├─▶ ViT (swaps token embeddings for patch embeddings)
└─▶ Diffusion (UNet uses ResBlocks + Transformer attention blocks)
MatMul kernel
└─▶ Softmax kernel (both are inner loops of attention)
└─▶ Flash Attention (fuses them)
Mini vLLM — E2E Architecture Reference
Projects: Inference Server (C++/Rust) · KV Cache Paging System · Speculative Decoding · Quantization Library (Int8/FP4) · Continuous Batching
What You're Building
The serving stack that takes a trained model and makes it run efficiently in production. This is what separates a model that runs on one GPU for one user from a system that serves thousands of concurrent requests, maximizing GPU utilization while minimizing latency. After completing this system you will understand every design decision behind vLLM, TensorRT-LLM, and TGI.
Project 1 — Inference Server (C++/Rust)
Full System Architecture
┌──────────────────────────────────────┐
│ INFERENCE SERVER │
│ │
Client ──HTTP/gRPC──▶ │ ┌─────────────┐ │
│ │ API Layer │ /v1/completions │
│ │ (REST/gRPC)│ /v1/chat │
│ └──────┬──────┘ /v1/embeddings │
│ │ │
│ ┌──────▼──────────────────────────┐ │
│ │ Request Queue │ │
│ │ Priority queue or FIFO │ │
│ │ Fields per request: │ │
│ │ - request_id (uuid) │ │
│ │ - prompt_token_ids │ │
│ │ - sampling_params │ │
│ │ - max_new_tokens │ │
│ │ - arrival_timestamp │ │
│ └──────┬──────────────────────────┘ │
│ │ │
│ ┌──────▼──────────────────────────┐ │
│ │ Scheduler │ │
│ │ Continuous batching engine │ │
│ │ - Groups prefill + decode │ │
│ │ - Preemption on OOM │ │
│ │ - Budget: max_batch_tokens │ │
│ └──────┬──────────────────────────┘ │
│ │ │
│ ┌──────▼────────────────────────┐ │
│ │ KV Cache Manager │ │
│ │ Page allocation/deallocation │ │
│ │ Prefix cache lookup │ │
│ └──────┬────────────────────────┘ │
│ │ │
│ ┌──────▼────────────────────────┐ │
│ │ Model Runner │ │
│ │ CUDA kernel dispatch │ │
│ │ Prefill + Decode batches │ │
│ └──────┬────────────────────────┘ │
│ │ │
│ ┌──────▼────────────────────────┐ │
│ │ Sampler │ │
│ │ temp / top-p / top-k / rep │ │
│ └──────┬────────────────────────┘ │
│ │ │
│ ┌──────▼────────────────────────┐ │
│ │ Detokenizer │ │
│ │ BPE decode → UTF-8 │ │
│ │ Streaming via SSE │ │
│ └───────────────────────────────┘ │
└──────────────────────────────────────┘
Threading Model (C++)
// Main thread: HTTP server
auto server = httplib::Server();
server.Post("/v1/completions", [&](const Request& req, Response& res) {
auto r = parse_request(req.body);
r.id = generate_uuid();
request_queue.push(r);
// Wait on promise/future for response
auto result = r.promise.get_future().get();
res.set_content(result.to_json(), "application/json");
});
// Scheduler thread: continuous batching loop
void scheduler_loop() {
while (running) {
auto batch = scheduler.get_next_batch(request_queue, kv_cache_manager);
if (batch.empty()) { std::this_thread::sleep_for(1ms); continue; }
auto outputs = model_runner.forward(batch);
sampler.sample(outputs, batch);
for (auto& req : batch.finished_requests)
req.promise.set_value(req.output);
scheduler.update(batch); // update state, free completed requests
}
}
Request Lifecycle
1. ARRIVE
Client sends POST /v1/completions
Server validates, assigns request_id, enqueues
2. WAIT IN QUEUE
Scheduler checks memory availability before admitting
If insufficient KV pages: request waits or lower-priority req preempted
3. PREFILL (first forward pass)
All prompt tokens processed in PARALLEL
Fills KV cache for all prompt positions
Most compute-intensive step: O(prompt_len²) attention
4. DECODE (subsequent forward passes)
ONE token generated per forward pass
Uses cached K,V from previous positions
Appends new K,V to cache
Memory-bandwidth bound (reads weights + KV cache each step)
5. SAMPLING
Logits (vocab_size) → apply temperature/top-p → sample token
Check stop conditions: max_tokens, stop strings, EOS token
6. STREAM / RETURN
SSE: send token immediately as generated
Non-streaming: buffer all tokens, return when done
7. CLEANUP
Free KV cache pages
Decrement reference counts (prefix cache sharing)
Update metrics (tokens/sec, latency p50/p99/p999)
Metrics & Observability
Prometheus metrics to expose:
- inference_requests_total (counter, by model/status)
- inference_tokens_per_second (gauge)
- kv_cache_utilization (gauge, 0-1)
- request_queue_depth (gauge)
- time_to_first_token_seconds (histogram)
- inter_token_latency_seconds (histogram)
- batch_size (histogram)
Logging: structured JSON logs per request
{ request_id, prompt_tokens, completion_tokens, latency_ms, model }
Project 2 — KV Cache Paging System (like vLLM)
The Problem
NAIVE APPROACH
Pre-allocate KV buffer: (max_seq_len, d_model) per request
For max_seq_len=2048, d=4096, 32 layers, FP16:
2048 * 4096 * 2 * 32 * 2 bytes = 1 GB per request
8x A100 (640 GB total) → ~640 concurrent requests MAX
Reality: average output < 200 tokens → 90% of allocation wasted
Memory fragmentation prevents packing more requests
PAGED ATTENTION SOLUTION
Manage KV memory like OS manages RAM:
Physical memory = fixed-size pages (blocks)
Virtual memory = logical view per request
Page table = mapping logical → physical blocks
Memory Layout
PHYSICAL BLOCK POOL (GPU HBM)
Total HBM: 80 GB (A100)
Model weights: ~14 GB (7B param FP16)
Activation mem: ~4 GB (batch processing)
KV Cache Pool: ~60 GB available
Block size: 16 tokens
Per-block size: 2 (K+V) × d_kv × n_layers × dtype_size
= 2 × 128 × 32 × 2 = 16 KB per block
Total blocks: 60 GB / 16 KB = ~3.8M blocks
BLOCK STRUCTURE (in GPU memory)
block[b].key = float16[n_layers, n_heads, BLOCK_SIZE, d_head]
block[b].value = float16[n_layers, n_heads, BLOCK_SIZE, d_head]
Alternatively flat: block[b] = float16[2, n_layers, n_heads, BLOCK_SIZE, d_head]
Block Manager (CPU-side)
class BlockManager:
def __init__(self, n_blocks, block_size):
self.n_blocks = n_blocks
self.block_size = block_size
self.free_blocks = deque(range(n_blocks)) # block_ids
self.ref_count = [0] * n_blocks
self.prefix_cache = {} # hash(tokens) → block_id
def allocate(self, n_tokens: int) -> List[int]:
"""Allocate physical blocks for n_tokens."""
n_blocks = ceil(n_tokens / self.block_size)
if len(self.free_blocks) < n_blocks:
raise OOMError("Insufficient KV cache pages")
allocated = []
for _ in range(n_blocks):
block_id = self.free_blocks.popleft()
self.ref_count[block_id] = 1
allocated.append(block_id)
return allocated
def free(self, block_table: List[int]):
"""Release blocks back to pool."""
for block_id in block_table:
self.ref_count[block_id] -= 1
if self.ref_count[block_id] == 0:
self.free_blocks.append(block_id)
def fork(self, block_table: List[int]) -> List[int]:
"""Copy-on-write fork (for beam search / prefix sharing)."""
new_table = block_table.copy()
for block_id in new_table:
self.ref_count[block_id] += 1
return new_table
def get_or_cache_prefix(self, tokens: List[int]) -> Optional[List[int]]:
"""Return cached blocks for a prompt prefix if available."""
for length in range(len(tokens), 0, -self.block_size):
prefix = tokens[:length]
key = hash(tuple(prefix))
if key in self.prefix_cache:
cached_blocks = self.prefix_cache[key]
# Increment ref counts (shared ownership)
for b in cached_blocks:
self.ref_count[b] += 1
return cached_blocks, length # blocks + how many tokens covered
return None, 0
Paged Attention CUDA Kernel
Standard attention reads K,V from contiguous tensors:
K: [batch, heads, seq_len, d_head] ← contiguous
Paged attention reads K,V via indirection through block table:
block_table: [batch, max_blocks_per_seq] ← CPU-provided mapping
Kernel pseudocode:
For each query position (batch b, head h, seq position q):
acc = 0.0
for logical_block = 0 to num_blocks:
phys_block = block_table[b, logical_block]
for slot = 0 to BLOCK_SIZE:
k = K_cache[phys_block, h, slot, :]
v = V_cache[phys_block, h, slot, :]
score = dot(q_vec, k) / sqrt(d_head)
acc += softmax_weight * v
output[b, h, q, :] = acc
Memory access pattern: non-contiguous but coalesced within a block
Performance overhead vs contiguous: ~10-15% (acceptable for 3x+ capacity gain)
Preemption Strategy
WHEN: new request admitted but KV cache runs OOM mid-generation
OPTIONS:
1. SWAP to CPU RAM
- Copy physical blocks from GPU HBM → CPU DRAM
- Resume later by swapping back
- Overhead: PCIe bandwidth (~32 GB/s) — slow
2. RECOMPUTE (drop and restart)
- Free all blocks for the preempted request
- When rescheduled: re-run prefill from scratch
- Simpler to implement, works well for short prompts
3. PARTIAL SWAP
- Keep recent blocks on GPU (needed for decode)
- Swap older/prefix blocks to CPU
- Best for long-context requests
SCHEDULING POLICY:
Preempt request with lowest priority (FCFS: oldest completed most tokens)
Never preempt the same request twice in a row (livelock prevention)
Project 3 — Speculative Decoding System
Core Algorithm
PROBLEM: LLM decode is 1 token/forward-pass — memory BW bound
Target model (7B) runs at ~50 tokens/sec on A100
INSIGHT: GPU can run one 7B forward pass OR seven 7B forward passes
in nearly the same wall-clock time (latency bound, not throughput)
Instead: draft K tokens with tiny model, verify ALL in one pass
Draft Model Target Model
(68M params) (7B params)
──────────── ────────────
Step 1: [fast, K tokens] [idle]
Step 2: [idle] [verify K+1 positions in one pass]
Step 3: accept/reject each draft token
If all K accepted → K+1 tokens generated in cost of 1 target forward pass
If first token rejected → 1 token, discard rest
Average speedup: 2-3x depending on task/acceptance rate
Full Algorithm (Speculative Sampling)
def speculative_decode(
target_model, draft_model, context, max_tokens, K=5
):
output = context.copy()
while len(output) - len(context) < max_tokens:
# ─── DRAFT PHASE ───────────────────────────────────────────
draft_tokens = []
draft_probs = []
draft_input = output.copy()
for _ in range(K):
logits = draft_model.forward(draft_input)
probs = softmax(logits[-1]) # last position
token = sample(probs) # draft sample
draft_tokens.append(token)
draft_probs.append(probs[token])
draft_input.append(token)
# ─── VERIFY PHASE (ONE TARGET FORWARD PASS) ────────────────
# Process output + all K draft tokens simultaneously
target_logits = target_model.forward(output + draft_tokens)
# target_logits has shape (K+1, vocab_size)
# position i gives distribution over token at position len(output)+i
# ─── ACCEPTANCE SAMPLING ───────────────────────────────────
n_accepted = 0
for i in range(K):
target_p = softmax(target_logits[i])[draft_tokens[i]]
draft_p = draft_probs[i]
r = random.uniform(0, 1)
if r < min(1.0, target_p / draft_p): # accept
output.append(draft_tokens[i])
n_accepted += 1
else: # reject
# Sample from adjusted distribution
# p_adjusted[x] = max(0, target_p[x] - draft_p[x]) / Z
adj_probs = relu(softmax(target_logits[i]) - draft_probs_full[i])
adj_probs /= adj_probs.sum()
output.append(sample(adj_probs))
break
else:
# All K accepted! Sample bonus token from final target position
output.append(sample(softmax(target_logits[K])))
n_accepted += 1 # always get at least 1 token
return output
# Key properties:
# - Provably identical distribution to running target model alone
# - Expected tokens per iteration = K * acceptance_rate + 1
# - No quality degradation, only speed change
Draft Model Selection
REQUIREMENTS:
- Same tokenizer as target (identical vocabulary essential)
- Much smaller (5-10x fewer params) for speed benefit
- Similar distribution to target (high acceptance rate)
STRATEGIES:
1. PURPOSE-TRAINED DRAFT
Distill from target model → dedicated small model
Best acceptance rate (trained to match target exactly)
Example: Medusa (multi-head on target), EAGLE (feature-based draft)
2. SAME-FAMILY SMALL MODEL
Llama-3.1-8B draft → Llama-3.1-70B target
Good acceptance for in-distribution text
Acceptance rate: 70-85% for typical generation tasks
3. N-GRAM / RETRIEVAL DRAFT
For repetitive text (code completion, structured output)
Match recent context to generate candidates
Zero parameters, instant draft — but narrow applicability
4. MEDUSA (multi-head speculative)
Attach K extra LM heads to target model
Each head predicts token at position +1, +2, ..., +K
Draft and verify in same forward pass (no separate model)
Verification uses tree attention to evaluate multiple token paths
System Integration
MEMORY:
Draft model fits in same GPU (68M << 7B)
KV cache: maintain SEPARATE caches for draft and target
On rejection: rollback target KV cache to last accepted position
Draft KV rollback: trivial (just reset position pointer)
Target KV rollback: must track position, truncate last K entries
BATCHING:
Speculative decoding is naturally sequential per request
Batch speculative: run draft for all requests, then batch verify with target
Complexity: accepted length varies per request → variable-length batches
DYNAMIC K:
Adjust K based on observed acceptance rate:
acceptance_rate > 0.8 → increase K
acceptance_rate < 0.5 → decrease K
Track per-request or per-model exponential moving average
Project 4 — Quantization Library (Int8/FP4)
Why Quantization
Llama-3.1-70B weights:
FP32: 70B × 4 bytes = 280 GB (requires 4× A100 just for weights)
BF16: 70B × 2 bytes = 140 GB
Int8: 70B × 1 byte = 70 GB ← fits in 1× H100 (80 GB)
FP4: 70B × 0.5 byte = 35 GB ← fits in 1× A100 (40 GB)
Memory bandwidth also reduced proportionally → faster decode
Post-Training Quantization (PTQ) Pipeline
INPUT: Pre-trained FP32/BF16 model weights
OUTPUT: Quantized model + scale factors + zero points
STEP 1: Calibration
Run forward passes on ~512 calibration samples
Collect activation statistics: min, max, percentiles per layer
Important: outliers in activations (specific channels >> others)
STEP 2: Weight Quantization
For each weight tensor W of shape (out, in):
Per-tensor (cheapest):
scale = max(|W|) / 127
W_q = round(W / scale).clamp(-128, 127).to(int8)
W_dq = W_q.float() * scale # dequantized approximation
Per-channel (better accuracy):
scale_per_row = max(|W|, dim=1) / 127 # shape: (out,)
W_q = round(W / scale_per_row[:, None]).clamp(-128, 127).to(int8)
Per-group (best accuracy, ~4-8 tokens grouped):
group_size = 128
W reshaped: (out, in/128, 128)
scale per group
STEP 3: Activation Quantization (for Int8 GEMM)
Static: pre-computed scale from calibration data
Dynamic: compute scale at runtime from actual activation values
scale = max(|act|) / 127 (one pass over activation)
act_q = round(act / scale).clamp(-128, 127).to(int8)
STEP 4: Int8 GEMM
y = W_q @ x_q ← integer matrix multiply (fast)
y_float = y.float() * scale_W * scale_x ← dequantize output
GPTQ (Gradient-based PTQ, Weight-Only)
Insight: Minimizing per-layer output error is better than minimizing
weight quantization error directly
Algorithm (per linear layer):
for each column j in weight matrix W:
w_j = W[:, j] # column to quantize
quant(w_j) # quantize to Int4/Int8
error_j = w_j - dequant(quant(w_j)) # quantization error
# Compensate remaining columns for this error
# using second-order information (Hessian of activation covariance)
W[:, j+1:] -= error_j @ H_inv[j, j+1:]
# This is the "lazy batch" variant — update in groups of 128 cols
Result: quantized weights that minimize output error, not weight error
Layer-wise: run GPTQ on each transformer layer sequentially
Quality: FP16-comparable at Int4, with 4x memory reduction
Cost: ~1-4 GPU-hours for 7B model (one-time)
FP4 (NF4 — NormalFloat4)
MOTIVATION: weights of quantized networks follow roughly normal distribution
→ allocate bits based on quantile positions, not linear range
NF4 QUANTILE LEVELS (16 levels for 4 bits):
Compute 16 quantiles of N(0,1):
[-1.0, -0.6962, -0.5251, -0.3949, -0.2844, -0.1848, -0.0911, 0.0,
0.0911, 0.1848, 0.2844, 0.3949, 0.5251, 0.6962, 1.0, 1.0]
These are equally-spaced PROBABILITY levels, not value levels
QUANTIZATION:
Normalize weights: w_norm = w / max(|w|)
For each w_norm: find nearest NF4 level (index 0-15)
Store 4-bit index
DEQUANTIZATION:
Look up NF4 table by index
Multiply by stored scale (max absolute value)
DOUBLE QUANTIZATION (QLoRA trick):
Scale factors themselves are quantized (FP8)
Scale of scales: one FP32 value per group
Memory saving: 0.5 bits per parameter for scale storage
BITSANDBYTES IMPLEMENTATION:
Uses block-wise quantization: 64-weight blocks
Each block has own scale factor
GPU kernel: blockwise_fp4_dequant_gemm
Quantization-Aware Training (QAT)
IDEA: Simulate quantization during training so model learns to be robust
FAKE QUANTIZE op (differentiable):
Forward: x_q = round(x / scale) * scale ← quantize + dequantize
Backward: pass-through (Straight-Through Estimator)
∂L/∂x = ∂L/∂x_q (ignore quantization in backward)
TRAINING LOOP:
1. Initialize from FP32 pre-trained weights
2. Insert FakeQuantize nodes after linear layers
3. Fine-tune with small learning rate (1e-5 typical)
4. After training: extract quantized weights + scales
RESULT: better accuracy than PTQ, but requires training data + compute
Accuracy Recovery Techniques
PROBLEM: Quantization kills accuracy for certain layers
LLM.int8() solution (Dettmers et al):
OBSERVATION: 0.1% of weight channels contain "outliers" (>>100x average)
SOLUTION: decompose matmul:
- 99.9% of columns → Int8 quantization (fast)
- 0.1% outlier columns → FP16 (keep full precision)
- Merge results
SmoothQuant:
INSIGHT: activations are harder to quantize than weights (more outliers)
SOLUTION: mathematically transfer quantization difficulty from activations to weights
X_new = X / s (easier to quantize activations)
W_new = W * s (slightly harder to quantize weights)
XW = X_new * W_new (same result)
where s is per-channel smoothing factor derived from activation stats
Project 5 — Continuous Batching
The Problem with Static Batching
STATIC BATCHING (naive)
Collect B requests, run together, return when ALL finish
Request 1: ████████ (8 tokens)
Request 2: ██████████████████████ (22 tokens)
Request 3: ████ (4 tokens)
Request 4: ██████████████ (14 tokens)
All start together. All wait for Request 2 to finish.
GPU utilization during tail: 25% (only req 2 running)
Batch throughput bottlenecked by longest request in batch.
Continuous Batching Solution
CONTINUOUS BATCHING (iteration-level scheduling)
After each decode step: check if any request finished
If yes: evict finished request, admit new request from queue
Timeline:
Step 1: [R1 R2 R3 R4] ← initial batch
Step 2: [R1 R2 R3 R4]
Step 4: [R1 R2 R3 R4] ← R3 finishes, R5 admitted
Step 5: [R1 R2 R5 R4]
Step 8: [R1 R2 R5 R4] ← R1 finishes, R6 admitted
Step 9: [R6 R2 R5 R4]
...
GPU always busy, no idle slots waiting for slow requests
Throughput improvement: 2-4x in practice
Scheduler Implementation
class ContinuousBatchingScheduler:
def __init__(self, max_batch_tokens, kv_manager):
self.max_batch_tokens = max_batch_tokens # budget
self.kv_manager = kv_manager
self.running: List[Request] = [] # currently decoding
self.waiting: Queue[Request] = Queue() # admitted, waiting for KV
self.swapped: List[Request] = [] # preempted to CPU
def schedule(self) -> SchedulerOutput:
# 1. Check finished requests
for req in self.running:
if req.is_done():
self.kv_manager.free(req.block_table)
self.running = [r for r in self.running if not r.is_done()]
# 2. Try to resume swapped requests
while self.swapped and self._has_capacity():
req = self.swapped.pop(0)
self._swap_in(req)
self.running.append(req)
# 3. Admit new requests from waiting queue
while not self.waiting.empty() and self._has_capacity():
req = self.waiting.get()
try:
blocks = self.kv_manager.allocate(len(req.prompt_tokens))
req.block_table = blocks
self.running.append(req)
except OOMError:
# Preempt lowest priority running request
victim = min(self.running, key=lambda r: r.priority)
self._swap_out(victim)
self.running.remove(victim)
self.swapped.append(victim)
# 4. Construct batch
prefill_reqs = [r for r in self.running if r.phase == 'prefill']
decode_reqs = [r for r in self.running if r.phase == 'decode']
return SchedulerOutput(
prefill=prefill_reqs,
decode=decode_reqs,
)
def _has_capacity(self) -> bool:
used_tokens = sum(len(r.token_ids) for r in self.running)
free_blocks = self.kv_manager.n_free_blocks()
return used_tokens < self.max_batch_tokens and free_blocks > MIN_FREE_BLOCKS
Batching Prefill and Decode Together
CHALLENGE: Prefill processes N tokens in parallel; Decode processes 1 token.
They have fundamentally different compute profiles.
CHUNKED PREFILL:
Long prompt would dominate a batch (thousands of tokens)
Solution: chunk prefill into pieces of max_chunk_size tokens
Interleave prefill chunks with decode steps
Schedule iteration:
Decode batch: all running requests (1 token each)
Prefill batch: one chunk from the next waiting request
Combined as single forward pass:
attention_mask handles that decode positions attend over full KV cache
while prefill chunk only attends over its own context
SEPARATION (simpler):
Two forward passes per scheduler step:
1. Prefill forward: all new requests in parallel
2. Decode forward: all decoding requests
Slightly less GPU utilization but simpler code
Token Budget Management
BUDGET CONSTRAINT: max_tokens_per_batch = max_batch_tokens
Decode batch:
Each request contributes exactly 1 input token
B decode requests → B tokens of compute (cheap per request)
KV reads: B requests × seq_len × d_kv (dominant cost)
Prefill batch:
New request contributes prompt_len tokens
10 requests of 200 tokens = 2000 tokens → quadratic attention
DYNAMIC BUDGET:
Reserve 80% of budget for decode (maintain latency)
Use remaining 20% for prefill (admit new requests)
decode_slots = floor(0.8 * max_batch_tokens)
prefill_budget = max_batch_tokens - actual_decode_tokens
admit_up_to = requests whose prompt_len fits in prefill_budget
How These 5 Projects Form One System
┌──────────────────────────────────────────┐
│ MINI vLLM │
│ │
│ ┌─────────────┐ ┌────────────────┐ │
Request ────────▶ │ │ Inference │──▶│ Continuous │ │
│ │ Server │ │ Batching │ │
│ └─────────────┘ └───────┬────────┘ │
│ │ │
│ ┌────────▼───────┐ │
│ │ KV Cache │ │
│ │ Paging │ │
│ └────────┬───────┘ │
│ │ │
│ ┌──────────────────▼────────┐ │
│ │ Quantized Model (Int8) │ │
│ │ + Speculative Decoding │ │
│ └───────────────────────────┘ │
└──────────────────────────────────────────┘
Memory hierarchy of a production serving system:
GPU SRAM (~50 MB): Flash Attention tiles, active batch activations
GPU HBM (~80 GB): Model weights (quantized) + KV cache (paged)
CPU DRAM (~512 GB): Swapped KV pages, request queue, tokenizer
SSD (~10 TB): Model checkpoint, serving logs
Performance stack:
Quantization → 2-4x less memory → more requests in GPU simultaneously
KV Paging → 3x better GPU memory utilization vs static allocation
Continuous Batch → 2-4x throughput vs static batching
Speculative Dec → 2-3x latency reduction for individual requests
Combined: production system serving 10-50x more requests/sec vs naive impl
Mini LangChain — E2E Architecture Reference
Projects: Chain-of-Thought Reasoner · ReAct Agent Loop · RAG Pipeline · Vector Database (HNSW) · Function Calling Router · Structured Output Parser · Semantic Router · Graph RAG · Knowledge Graph Builder · Code Interpreter Sandbox
What You're Building
The application layer that makes LLMs useful: orchestrating reasoning, retrieval, tool use, and structured outputs. After completing this system you understand how every major agent framework works internally, why retrieval quality determines application quality, and how to route and constrain LLM outputs programmatically.
Project 1 — Chain-of-Thought Reasoner
Architecture
STANDARD PROMPTING
Prompt: "What is 17 × 23?"
Output: "391"
Problem: Model pattern-matches, doesn't show work
Errors compound invisibly on multi-step problems
CHAIN-OF-THOUGHT PROMPTING
Prompt: "What is 17 × 23? Think step by step."
Output: "17 × 23 = 17 × 20 + 17 × 3 = 340 + 51 = 391"
Performance improvement:
GSM8K (math): 18% → 57% (GPT-3 scale)
MATH dataset: larger gains on harder problems
Works best at >100B params (emergent capability)
Implementation Architecture
CoT SYSTEM ARCHITECTURE
┌───────────────────────────────────────────────────────────┐
│ REASONER │
│ │
│ Input: question │
│ ┌──────────────────────────────────────────────┐ │
│ │ PROMPT BUILDER │ │
│ │ │ │
│ │ System: "You are a careful reasoner. │ │
│ │ Think step by step before │ │
│ │ answering." │ │
│ │ │ │
│ │ Few-shot examples: │ │
│ │ Q: [example question] │ │
│ │ A: Let me think step by step. │ │
│ │ [reasoning steps...] │ │
│ │ Therefore, the answer is [X]. │ │
│ │ │ │
│ │ Q: {question} │ │
│ │ A: Let me think step by step. │ │
│ └──────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ LLM API │ │
│ │ temperature = 0.0 │ ← deterministic │
│ │ stop = ["Q:"] │ ← stop before new Q │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ OUTPUT PARSER │ │
│ │ Extract reasoning │ │
│ │ Extract final ans │ │
│ └─────────────────────┘ │
└───────────────────────────────────────────────────────────┘
CoT Variants
ZERO-SHOT CoT
Simply append: "Let's think step by step."
No examples needed. Surprisingly effective.
Best when: question domain is clear, model is large
FEW-SHOT CoT
Provide 4-8 examples with explicit reasoning chains
Examples must match domain (math examples for math tasks)
Best when: specialized domain, consistent format needed
SELF-CONSISTENCY CoT
Generate N reasoning paths (temperature > 0)
Final answer: majority vote across N outputs
N=40 paths typical. Trades compute for accuracy.
+3-5% accuracy over single chain
PROGRAM-OF-THOUGHT (PoT)
Instead of natural language reasoning → generate code
Execute code to get exact answer (no arithmetic errors)
for i, step in reasoning_steps:
# Each step is Python code
result = exec(step)
Especially powerful for math/computation
TREE-OF-THOUGHTS (ToT)
Maintain beam of k partial reasoning paths
Score each state with LLM: "Is this reasoning correct?"
Expand promising paths, prune bad ones
BFS or DFS through reasoning tree
Expensive but solves multi-step strategic problems
Implementation
class ChainOfThoughtReasoner:
def __init__(self, llm_client, examples: List[Example]):
self.llm = llm_client
self.examples = examples # (question, reasoning, answer) tuples
def build_prompt(self, question: str) -> str:
prompt = "You are a careful reasoner. Solve problems step by step.\n\n"
for ex in self.examples:
prompt += f"Q: {ex.question}\n"
prompt += f"A: Let me think step by step.\n{ex.reasoning}\n"
prompt += f"Therefore, the answer is: {ex.answer}\n\n"
prompt += f"Q: {question}\n"
prompt += "A: Let me think step by step.\n"
return prompt
def reason(self, question: str, n_samples: int = 1) -> str:
prompt = self.build_prompt(question)
if n_samples == 1:
response = self.llm.complete(prompt, temperature=0.0, stop=["Q:"])
return self.parse_answer(response)
else:
# Self-consistency: majority vote
answers = []
for _ in range(n_samples):
resp = self.llm.complete(prompt, temperature=0.7, stop=["Q:"])
answers.append(self.parse_answer(resp))
return Counter(answers).most_common(1)[0][0]
def parse_answer(self, response: str) -> str:
# Extract final answer after "Therefore" or "The answer is"
patterns = [
r"[Tt]herefore.*?(?:answer is|=)\s*(.+?)(?:\.|$)",
r"[Tt]he answer is:?\s*(.+?)(?:\.|$)",
r"=\s*(\d+\.?\d*)\s*$",
]
for p in patterns:
match = re.search(p, response)
if match: return match.group(1).strip()
# Fallback: last line
return response.strip().split('\n')[-1]
Project 2 — ReAct Agent Loop
Architecture
ReAct = Reason + Act
Core loop: Thought → Action → Observation → Thought → ...
AGENT LOOP ARCHITECTURE
─────────────────────────
┌─────────────────────────────────────────────────────────┐
│ REACT AGENT │
│ │
│ User Input ──▶ "What's the weather in Tokyo today │
│ and should I bring an umbrella?" │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ CONTEXT WINDOW │ │
│ │ System prompt: "You are a helpful agent. │ │
│ │ Use tools to answer questions. │ │
│ │ Tools available: [weather_api, web_search] │ │
│ │ │ │
│ │ To use a tool, write: │ │
│ │ Thought: I need to check the weather │ │
│ │ Action: weather_api │ │
│ │ Action Input: {"city": "Tokyo"} │ │
│ │ The result will be returned as Observation. │ │
│ │ When done, write: Final Answer: [answer]" │ │
│ └─────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ LLM │ │
│ └──────────┬──────────┘ │
│ │ generates: │
│ │ "Thought: I need to look up │
│ │ current weather in Tokyo │
│ │ Action: weather_api │
│ │ Action Input: {city: Tokyo}" │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ OUTPUT PARSER │ parse action/input │
│ └──────────┬──────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ TOOL EXECUTOR │ │
│ │ weather_api(Tokyo) │ │
│ └──────────┬──────────┘ │
│ │ returns: │
│ │ "Rainy, 18°C, 90% precipitation│
│ │ │
│ Append to context: │
│ "Observation: Rainy, 18°C, 90% precipitation" │
│ │ │
│ [loop back to LLM] │
│ │ │
│ LLM generates: │
│ "Final Answer: Yes, bring an umbrella..." │
└─────────────────────────────────────────────────────────┘
Full Implementation
class ReActAgent:
def __init__(self, llm, tools: List[Tool], max_steps=10):
self.llm = llm
self.tools = {t.name: t for t in tools}
self.max_steps = max_steps
def build_system_prompt(self) -> str:
tool_descriptions = "\n".join(
f"- {t.name}: {t.description}\n Input schema: {t.input_schema}"
for t in self.tools.values()
)
return f"""You are a helpful agent with access to tools.
Available tools:
{tool_descriptions}
To use a tool, respond with:
Thought: [your reasoning]
Action: [tool_name]
Action Input: [JSON input]
When you have the final answer, respond with:
Thought: [final reasoning]
Final Answer: [your answer]
Always think before acting. Always use exact tool names."""
def run(self, user_input: str) -> AgentResult:
messages = [
{"role": "system", "content": self.build_system_prompt()},
{"role": "user", "content": user_input}
]
trajectory = []
for step in range(self.max_steps):
# Generate next thought/action
response = self.llm.chat(messages)
messages.append({"role": "assistant", "content": response})
# Check if done
if "Final Answer:" in response:
answer = response.split("Final Answer:")[-1].strip()
return AgentResult(answer=answer, trajectory=trajectory)
# Parse action
try:
thought, action, action_input = self.parse_action(response)
except ParseError as e:
# Give LLM a chance to correct itself
messages.append({"role": "user", "content": f"Parse error: {e}. Please try again."})
continue
# Execute tool
if action not in self.tools:
observation = f"Error: tool '{action}' not found. Available: {list(self.tools.keys())}"
else:
try:
observation = self.tools[action].execute(action_input)
except Exception as e:
observation = f"Tool error: {str(e)}"
trajectory.append(Step(thought=thought, action=action,
action_input=action_input, observation=str(observation)))
# Append observation to messages
messages.append({"role": "user", "content": f"Observation: {observation}"})
# Exceeded max steps
return AgentResult(answer="Could not complete within max steps", trajectory=trajectory)
def parse_action(self, text: str) -> tuple:
thought_match = re.search(r"Thought:(.*?)(?:Action:|$)", text, re.DOTALL)
action_match = re.search(r"Action:\s*(\w+)", text)
input_match = re.search(r"Action Input:\s*(\{.*?\})", text, re.DOTALL)
if not action_match: raise ParseError("No Action found")
thought = thought_match.group(1).strip() if thought_match else ""
action = action_match.group(1).strip()
action_input = json.loads(input_match.group(1)) if input_match else {}
return thought, action, action_input
Tool Interface
@dataclass
class Tool:
name: str
description: str
input_schema: dict # JSON schema for input validation
func: Callable
def execute(self, inputs: dict) -> Any:
# Validate inputs against schema
validate(inputs, self.input_schema)
return self.func(**inputs)
# Example tools
web_search = Tool(
name="web_search",
description="Search the internet for current information",
input_schema={"type": "object", "properties": {"query": {"type": "string"}}},
func=lambda query: search_api(query)
)
python_repl = Tool(
name="python_repl",
description="Execute Python code and return the output",
input_schema={"type": "object", "properties": {"code": {"type": "string"}}},
func=lambda code: sandbox_exec(code) # sandboxed!
)
calculator = Tool(
name="calculator",
description="Evaluate mathematical expressions",
input_schema={"type": "object", "properties": {"expression": {"type": "string"}}},
func=lambda expression: eval_math_safely(expression)
)
Project 3 — RAG Pipeline
Full Architecture
┌─────────────────────────────────────────────┐
│ RAG SYSTEM │
│ │
INDEXING TIME: │ │
Documents ──▶ │ ┌──────────────────────────────────────┐ │
│ │ INGESTION PIPELINE │ │
│ │ │ │
│ │ Load → Chunk → Embed → Store │ │
│ │ │ │
│ │ Loaders: PDF, HTML, DOCX, MD, CSV │ │
│ │ Chunking: fixed-size or semantic │ │
│ │ Embedder: text-embedding-3-large │ │
│ │ Store: VectorDB + metadata store │ │
│ └──────────────────────────────────────┘ │
│ │
QUERY TIME: │ │
Question ────▶ │ ┌──────────────────────────────────────┐ │
│ │ RETRIEVAL ENGINE │ │
│ │ │ │
│ │ Query embed → ANN search → Rerank │ │
│ └─────────────────┬────────────────────┘ │
│ │ top-k chunks │
│ ┌─────────────────▼────────────────────┐ │
│ │ GENERATION ENGINE │ │
│ │ │ │
│ │ Build prompt with context + question │ │
│ │ LLM generates grounded answer │ │
│ │ Citation extraction │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
Chunking Strategies
FIXED-SIZE CHUNKING
Split by character count (e.g., 512 chars)
Overlap: 50-100 chars to avoid context loss at boundaries
chunk_size = 512
chunk_overlap = 50
chunks = []
for i in range(0, len(text), chunk_size - chunk_overlap):
chunks.append(text[i : i + chunk_size])
Pros: simple, predictable
Cons: splits mid-sentence, mid-concept
RECURSIVE CHARACTER SPLITTER (better)
Try to split on: \n\n, \n, ". ", " ", ""
Use largest separator that keeps chunks ≤ chunk_size
Preserves paragraph/sentence boundaries when possible
SEMANTIC CHUNKING
Embed each sentence
Split when cosine similarity between adjacent sentences drops
threshold = mean(similarities) - 1.5 * std(similarities)
Produces semantically coherent chunks
Pros: best retrieval quality; Cons: slower, requires embeddings
DOCUMENT-AWARE
Respect document structure: headings, paragraphs, tables
Keep tables together (don't split table rows)
Keep code blocks together
Requires document structure understanding (markdown, HTML parser)
Retrieval Strategies
DENSE RETRIEVAL (standard)
embed(query) → cosine_sim with all chunk embeddings → top-k
SPARSE RETRIEVAL (BM25)
Term frequency + inverse document frequency scoring
Good for: exact keyword matches, rare terms
Implementation: elasticsearch, bm25s library
HYBRID RETRIEVAL (best of both)
scores_dense = cosine_sim(q_embed, chunk_embeds) # (n_chunks,)
scores_sparse = bm25_score(query_tokens, chunk_tokens) # (n_chunks,)
# Reciprocal Rank Fusion
rank_dense = argsort(argsort(-scores_dense)) + 1
rank_sparse = argsort(argsort(-scores_sparse)) + 1
rrf_score = 1/(60 + rank_dense) + 1/(60 + rank_sparse)
top_k = argsort(-rrf_score)[:k]
MULTI-QUERY RETRIEVAL
Generate 3-5 query variants using LLM:
"Write 5 different ways to ask: {original_query}"
Retrieve for each variant, deduplicate, merge results
Covers more semantic territory
HYDE (Hypothetical Document Embeddings)
Generate hypothetical answer: "Write a short passage that answers: {query}"
Embed the hypothetical answer (not the query)
Retrieve chunks similar to the hypothetical answer
Key insight: answer-to-document similarity > question-to-document similarity
CONTEXTUAL COMPRESSION
Retrieve top-20 chunks
For each chunk: ask LLM "Is this relevant to {query}? Extract relevant part."
Keep only relevant portions (reduces noise in context window)
Reranking
CROSS-ENCODER RERANKER
Fast bi-encoder retrieval → slower cross-encoder refinement
Bi-encoder: embed query and chunks SEPARATELY → dot product
Cross-encoder: concatenate [query, chunk] → single classifier score
More accurate (full attention between query and chunk)
Too slow for all documents; fine for top-50 reranking
from sentence_transformers import CrossEncoder
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
scores = reranker.predict([(query, chunk) for chunk in top_50_chunks])
top_k = sorted(zip(top_50_chunks, scores), key=lambda x: -x[1])[:k]
COLBERT (late interaction)
Embed query tokens and document tokens separately
Score = MaxSim: sum of max cosine similarities between query tokens and doc tokens
Faster than cross-encoder, better than bi-encoder
Stores token embeddings per document (more storage)
Generation with Citations
def generate_answer(query: str, retrieved_chunks: List[Chunk]) -> Answer:
context_parts = []
for i, chunk in enumerate(retrieved_chunks):
context_parts.append(f"[{i+1}] Source: {chunk.source}\n{chunk.text}")
context = "\n\n".join(context_parts)
prompt = f"""Answer the question based on the provided sources.
After each claim, cite the source number like [1], [2], etc.
If the sources don't contain enough information, say so.
Sources:
{context}
Question: {query}
Answer:"""
response = llm.complete(prompt, temperature=0.0)
# Extract citations from response
citation_numbers = re.findall(r'\[(\d+)\]', response)
cited_chunks = [retrieved_chunks[int(n)-1] for n in set(citation_numbers)
if int(n) <= len(retrieved_chunks)]
return Answer(text=response, citations=cited_chunks, sources=[c.source for c in cited_chunks])
Project 4 — Vector Database (HNSW Index)
HNSW Overview
HNSW (Hierarchical Navigable Small World) — the algorithm powering Pinecone, Weaviate, Chroma. Approximate nearest neighbor with logarithmic search complexity.
Data Structure
LAYERED GRAPH
Layer 0: ALL nodes (dense, short-range connections)
Layer 1: ~1/m fraction of nodes (sparser)
Layer 2: ~1/m² fraction of nodes
...
Layer L_max: ~1 node (entry point)
Each node:
- vector: float32[d] ← the embedding
- neighbors[layer]: List[node_id] ← up to M neighbors per layer
- layer_max: int ← max layer this node appears in
Node's max layer assigned randomly:
layer = floor(-ln(random()) * m_L) ← exponential distribution
m_L = 1/ln(M) (controls layer density)
Insert Algorithm
def insert(hnsw, q_vec: np.ndarray, M=16, ef_construction=200):
"""Insert a new vector into the HNSW index."""
q_layer = random_layer() # sample from exponential distribution
q = Node(vector=q_vec, layer_max=q_layer)
# Start from entry point at top layer
ep = hnsw.entry_point
# Phase 1: Greedy descent to q_layer+1 (1 neighbor per level)
for lc in range(hnsw.L_max, q_layer + 1, -1):
ep = greedy_search_1(hnsw, q_vec, ep, layer=lc)
# Just move greedily to closest node, don't add connections
# Phase 2: Build connections from L_max down to layer 0
for lc in range(min(q_layer, hnsw.L_max), -1, -1):
# Beam search: find ef_construction candidates at this layer
candidates = beam_search(hnsw, q_vec, ep, ef=ef_construction, layer=lc)
# Select M best neighbors using heuristic
neighbors = select_neighbors_heuristic(q_vec, candidates, M)
# Add bidirectional connections
q.neighbors[lc] = neighbors
for nb in neighbors:
nb.neighbors[lc].append(q)
# Prune if degree exceeds M_max (keep M_max closest)
if len(nb.neighbors[lc]) > M_max:
nb.neighbors[lc] = select_neighbors_heuristic(
nb.vector, nb.neighbors[lc], M_max
)
ep = candidates[0] # closest found → entry for next layer
if q_layer > hnsw.L_max:
hnsw.entry_point = q
hnsw.L_max = q_layer
Search Algorithm
def search(hnsw, q_vec: np.ndarray, k=10, ef=100) -> List[Node]:
"""K nearest neighbor search."""
ep = hnsw.entry_point
# Phase 1: Greedy descent from top layer to layer 1
for lc in range(hnsw.L_max, 0, -1):
ep = greedy_search_1(hnsw, q_vec, ep, layer=lc)
# Phase 2: Beam search at layer 0 (full graph)
candidates = beam_search(hnsw, q_vec, ep, ef=max(ef, k), layer=0)
return candidates[:k]
def beam_search(hnsw, q_vec, entry_point, ef, layer):
"""Maintains a min-heap of ef candidates and max-heap of visited."""
visited = {entry_point.id}
candidates = MinHeap(key=lambda n: dist(q_vec, n.vector))
W = MaxHeap(key=lambda n: dist(q_vec, n.vector)) # result set
candidates.push(entry_point)
W.push(entry_point)
while candidates:
c = candidates.pop() # closest unprocessed
f = W.peek() # furthest in result set
if dist(q_vec, c.vector) > dist(q_vec, f.vector):
break # all remaining candidates are further than worst in W
for nb in c.neighbors[layer]:
if nb.id not in visited:
visited.add(nb.id)
f = W.peek()
if dist(q_vec, nb.vector) < dist(q_vec, f.vector) or len(W) < ef:
candidates.push(nb)
W.push(nb)
if len(W) > ef:
W.pop() # remove furthest
return sorted(W, key=lambda n: dist(q_vec, n.vector))
Performance Characteristics
BUILD time: O(N log N) — each insertion is O(log N) with beam search
Query time: O(log N) amortized — due to hierarchical structure
Memory: O(N × M × L) — N nodes, M neighbors, L layers
Typical parameters:
M = 16 → each node has ~16 bidirectional connections
ef_construction = 200 → quality/speed tradeoff during build
ef (search) = 100 → quality/speed tradeoff during search
Benchmarks (SIFT-1M dataset, 128-dim):
Recall@10: 0.97+ with ef=100
QPS: ~1000-5000 on single core (vs ~5 for brute force at recall 0.97)
Disk layout (for persistence):
vectors.bin: flat float32 array (N, d)
graph.bin: adjacency lists per node per layer
metadata.json: params + entry_point + L_max
Project 5 — Function Calling Router
Architecture
INPUT: User message + available function schemas
OUTPUT: Which function to call with what arguments
APPROACHES:
1. Native function calling (OpenAI/Anthropic API format)
2. Prompt-based routing (for any LLM)
3. Semantic similarity routing (fast, no LLM needed)
4. Hybrid: semantic shortlist → LLM selection
Implementation
class FunctionCallingRouter:
def __init__(self, functions: List[FunctionDef], llm, embedder=None):
self.functions = {f.name: f for f in functions}
self.llm = llm
self.embedder = embedder
# Pre-compute function description embeddings for fast routing
if embedder:
self.func_embeddings = {
f.name: embedder.embed(f.description)
for f in functions
}
def route(self, user_message: str, top_k_candidates=3) -> FunctionCall:
# Fast pre-filter using semantic similarity
if self.embedder:
query_embed = self.embedder.embed(user_message)
candidates = self._semantic_shortlist(query_embed, top_k_candidates)
else:
candidates = list(self.functions.values())
# LLM-based selection from candidates
return self._llm_select(user_message, candidates)
def _semantic_shortlist(self, q_embed, k) -> List[FunctionDef]:
scores = {
name: cosine_similarity(q_embed, emb)
for name, emb in self.func_embeddings.items()
}
top_names = sorted(scores, key=scores.get, reverse=True)[:k]
return [self.functions[n] for n in top_names]
def _llm_select(self, message: str, candidates: List[FunctionDef]) -> FunctionCall:
# Build function schemas for the prompt
schemas = [f.to_json_schema() for f in candidates]
# Use LLM's native function calling
response = self.llm.chat(
messages=[{"role": "user", "content": message}],
tools=schemas,
tool_choice="auto"
)
if response.tool_calls:
tool_call = response.tool_calls[0]
return FunctionCall(
name=tool_call.function.name,
arguments=json.loads(tool_call.function.arguments)
)
return FunctionCall(name=None, arguments={}) # no function needed
Function Schema Format
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name"
},
"units": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature units"
}
},
"required": ["city"]
}
}
}
Project 6 — Structured Output Parser (Context Free Grammars)
The Problem
LLM output: "The answer is 42. Wait, let me recalculate... I think it's 43."
Expected JSON: {"answer": 42}
LLMs often:
- Add preamble ("Sure! Here's the JSON you requested:")
- Add explanation after JSON
- Use wrong quote types or trailing commas
- Nest incorrectly
SOLUTION: Grammar-constrained decoding
At each sampling step, MASK OUT logits for tokens that would
produce invalid output according to the grammar.
The model CAN ONLY generate valid structured output.
Grammar-Constrained Decoding
JSON GRAMMAR (simplified BNF):
value ::= object | array | string | number | "true" | "false" | "null"
object ::= "{" (pair ("," pair)*)? "}"
pair ::= string ":" value
array ::= "[" (value ("," value)*)? "]"
string ::= '"' chars '"'
number ::= '-'? digits ('.' digits)?
PARSER STATE MACHINE:
Track current position in grammar derivation
At each step: compute VALID NEXT TOKENS
Mask logits: invalid_tokens → -∞
IMPLEMENTATION (llama.cpp grammar approach):
Grammar compiled to stack-based parser
Parser state = grammar stack + parse position
At each token generation step:
valid_tokens = grammar_parser.valid_next_tokens() → bitset
logits[~valid_tokens] = -inf
next_token = sample(softmax(logits))
grammar_parser.advance(next_token)
Pydantic-Based Schema Extraction
from pydantic import BaseModel
from typing import List, Optional
class ExtractedPerson(BaseModel):
name: str
age: Optional[int]
occupation: str
location: str
class ExtractionResult(BaseModel):
people: List[ExtractedPerson]
confidence: float
class StructuredOutputParser:
def __init__(self, llm, schema: Type[BaseModel]):
self.llm = llm
self.schema = schema
self.json_schema = schema.model_json_schema()
def parse(self, text: str) -> BaseModel:
prompt = f"""Extract information from the text and return ONLY a JSON object
matching this schema:
{json.dumps(self.json_schema, indent=2)}
Text: {text}
JSON (no other text):"""
# Try with grammar constraint first, fall back to retry
for attempt in range(3):
response = self.llm.complete(
prompt,
temperature=0.0,
response_format={"type": "json_object"} # if API supports it
)
try:
# Clean response (remove markdown fences if present)
clean = re.sub(r'```json\n?|```\n?', '', response).strip()
data = json.loads(clean)
return self.schema(**data)
except (json.JSONDecodeError, ValidationError) as e:
if attempt == 2: raise
# Retry with error feedback
prompt += f"\n\nPrevious attempt failed with error: {e}\nTry again:"
Project 7 — Semantic Router
Architecture
SEMANTIC ROUTER: Route queries to different handlers based on meaning
Without keyword matching or rule lists
EXAMPLES:
"What is machine learning?" → knowledge_handler
"Write me a poem" → creative_handler
"Book me a flight" → action_handler
"I'm feeling sad" → empathy_handler
COMPONENTS:
1. Route definitions (name + example utterances)
2. Encoder (converts utterances to vectors)
3. Index (fast similarity lookup)
4. Threshold logic (confidence scoring)
Implementation
class SemanticRouter:
def __init__(self, routes: List[Route], encoder: Encoder, threshold=0.75):
self.routes = routes
self.encoder = encoder
self.threshold = threshold
self.index = self._build_index()
def _build_index(self):
"""Embed all example utterances per route."""
index = {}
for route in self.routes:
embeddings = self.encoder.batch_encode(route.utterances)
index[route.name] = embeddings
return index
def route(self, query: str) -> RouteDecision:
q_embed = self.encoder.encode(query)
# Score each route: max similarity to any utterance
route_scores = {}
for route_name, embeddings in self.index.items():
sims = cosine_similarity(q_embed, embeddings) # (n_utterances,)
route_scores[route_name] = float(sims.max())
best_route = max(route_scores, key=route_scores.get)
best_score = route_scores[best_route]
if best_score >= self.threshold:
return RouteDecision(route=best_route, score=best_score)
else:
return RouteDecision(route="fallback", score=best_score)
# Route definitions
routes = [
Route(
name="weather",
utterances=[
"What's the weather like?",
"Is it going to rain tomorrow?",
"Temperature in London",
"Should I bring a coat?",
"Weather forecast for next week",
]
),
Route(
name="code_help",
utterances=[
"Help me debug this code",
"Why is my function returning None?",
"How do I implement a binary search?",
"Explain this Python error",
]
),
]
Project 8 — Graph RAG System
Why Graph RAG?
STANDARD RAG LIMITATION:
Query: "How did the acquisition of Company A affect Company B's market share
given that Company A was previously competing with Company C?"
Chunk retrieval: finds chunks about acquisition OR market share OR competition
But: multi-hop reasoning requires connecting MULTIPLE entities and relationships
Standard RAG: retrieves individually relevant chunks, misses connections
GRAPH RAG:
Extracts entities and relationships → builds knowledge graph
Query: traverse graph to find connected information
Answer: grounded in graph traversal + relevant chunks
Full Pipeline
INDEXING PIPELINE
───────────────────
Documents
│
▼
┌─────────────────────────────────────┐
│ ENTITY + RELATION EXTRACTION │
│ │
│ Prompt LLM: │
│ "Extract entities and relations │
│ from this text as JSON: │
│ {entities: [{name, type, desc}], │
│ relations: [{from, rel, to}]}" │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ ENTITY RESOLUTION │
│ │
│ "OpenAI" == "Open AI" == "OpenAI, │
│ Inc." → merge to canonical form │
│ │
│ Approach: │
│ 1. Exact match after normalization│
│ 2. Embedding similarity (>0.95) │
│ 3. LLM-based disambiguation │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ COMMUNITY DETECTION │
│ │
│ Run Leiden/Louvain algorithm │
│ Groups tightly connected entities │
│ Generate community summaries │
│ (LLM summarizes each community) │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ GRAPH STORAGE │
│ Neo4j / NetworkX / LightRAG │
│ Nodes: entities + properties │
│ Edges: typed relationships │
└─────────────────────────────────────┘
QUERY PIPELINE
───────────────
User Query
│
├──▶ Entity extraction from query
│ ["Company A", "Company B", "market share"]
│
├──▶ Local search: retrieve subgraph around query entities
│ 1-hop: direct neighbors
│ 2-hop: neighbors of neighbors
│ Prune by edge type relevance
│
├──▶ Global search: retrieve relevant community summaries
│ Embed query → find similar community summaries
│
├──▶ Vector search: find relevant chunks (standard RAG)
│
▼
CONTEXT ASSEMBLY
Subgraph (entities + relations as text)
Community summaries
Relevant chunks
│
▼
LLM GENERATION with grounded context
Citations link back to source chunks + graph paths
Project 9 — Code Interpreter Sandbox
Security Architecture
THREAT MODEL
Untrusted code from LLM or user executes on your server
Risks: file system access, network calls, fork bombs,
CPU exhaustion, memory exhaustion, container escape
ISOLATION LAYERS
Layer 1: Linux namespaces (separate pid, net, mnt, user)
Layer 2: seccomp syscall filtering (whitelist safe syscalls)
Layer 3: Resource limits (cgroups: CPU, RAM, disk)
Layer 4: Read-only filesystem (except /tmp)
Layer 5: No network access (isolated network namespace)
Layer 6: Timeout enforcement (SIGKILL after N seconds)
Implementation (Python subprocess + seccomp)
import subprocess, resource, tempfile, os
class CodeSandbox:
def __init__(self, timeout_seconds=10, memory_limit_mb=256):
self.timeout = timeout_seconds
self.memory_limit = memory_limit_mb * 1024 * 1024
def execute(self, code: str, language="python") -> ExecutionResult:
with tempfile.TemporaryDirectory() as tmpdir:
# Write code to temp file
code_file = os.path.join(tmpdir, f"solution.{language[:2]}")
with open(code_file, 'w') as f:
f.write(code)
# Build sandbox command using bubblewrap (bwrap)
cmd = [
"bwrap",
"--ro-bind", "/usr", "/usr",
"--ro-bind", "/lib", "/lib",
"--ro-bind", "/lib64", "/lib64",
"--bind", tmpdir, "/sandbox",
"--tmpfs", "/tmp",
"--proc", "/proc",
"--dev", "/dev",
"--unshare-all", # all namespaces
"--die-with-parent",
"--",
"python3", "-u", "/sandbox/solution.py"
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=self.timeout,
preexec_fn=self._set_limits
)
return ExecutionResult(
stdout=result.stdout,
stderr=result.stderr,
returncode=result.returncode,
timed_out=False
)
except subprocess.TimeoutExpired:
return ExecutionResult(stdout="", stderr="",
returncode=-1, timed_out=True)
def _set_limits(self):
"""Called in child process before exec — sets resource limits."""
resource.setrlimit(resource.RLIMIT_AS, (self.memory_limit, self.memory_limit))
resource.setrlimit(resource.RLIMIT_CPU, (self.timeout, self.timeout))
resource.setrlimit(resource.RLIMIT_NPROC, (50, 50))
resource.setrlimit(resource.RLIMIT_FSIZE, (10_000_000, 10_000_000)) # 10MB
How These 10 Projects Form One System
┌──────────────────────────────────────────────┐
│ MINI LANGCHAIN │
│ │
User Input ────▶ │ Semantic Router ──▶ route classification │
│ │ │
│ ┌────┴──────────────────────────────┐ │
│ │ │ │
│ RAG Path Agent Path │
│ │ │ │
│ Embed Query CoT Reasoning │
│ HNSW Search ReAct Loop │
│ Rerank Tool Calls: │
│ Graph RAG - Code Sandbox│
│ Context Build - Function Router│
│ │ - Web Search │
│ └────────────┬──────────────────────┘ │
│ │ │
│ Structured Output Parser │
│ (Grammar-constrained JSON) │
│ │ │
│ Final Response │
└──────────────────────────────────────────────┘
Data flow for complex query:
1. Semantic Router classifies intent
2. If knowledge query → RAG pipeline + Graph RAG
3. If action query → ReAct agent loop
4. CoT for multi-step reasoning within agent
5. Function calling for external tool invocation
6. Code sandbox for computation
7. Structured output parser for typed response
Mini Hugging Face — E2E Architecture Reference
Projects: Tokenizer (BPE) · LoRA Trainer · PEFT Library · Eval Harness · Model Merger (SLERP) · Embedding Model · Logit Processor · Guardrails System · Prompt Caching · Whisper-style ASR · Text-to-Speech Pipeline · Audio Spectrogram Transformer
What You're Building
The model management and adaptation stack: how raw text becomes tokens, how models are fine-tuned efficiently without touching most parameters, how models are evaluated rigorously, and how they're configured for deployment. This is the layer Hugging Face made famous — transformers, tokenizers, PEFT, evaluate — built from scratch.
Project 1 — Tokenizer (BPE Implementation)
What BPE Does
RAW TEXT: "low lower newest widest"
VOCABULARY: starts with individual bytes/characters
GOAL: iteratively merge most frequent adjacent pairs
RESULT: vocabulary of subword tokens that compresses text efficiently
WHY SUBWORDS:
- "unbelievable" = "un" + "believ" + "able" (not 1 unknown word)
- Handles morphology, typos, new words
- Fixed vocabulary size with near-complete text coverage
BPE Training Algorithm
def train_bpe(text: str, vocab_size: int) -> Tokenizer:
"""Train BPE tokenizer from scratch."""
# STEP 1: Initial vocabulary = all bytes (256 base tokens)
vocab = {i: bytes([i]) for i in range(256)}
merges = {} # (pair) → merged_token_id
# Pretokenize: split on whitespace, add word-boundary marker
# "low" → ["l", "o", "w", </w>] (</w> marks word end)
words = Counter(text.split())
word_tokens = {
word: list(word.encode()) + [WORD_END_TOKEN]
for word in words
}
word_freqs = dict(words)
# STEP 2: Iteratively merge until vocab_size reached
while len(vocab) < vocab_size:
# Count all adjacent pairs across corpus
pair_freqs = defaultdict(int)
for word, tokens in word_tokens.items():
freq = word_freqs[word]
for i in range(len(tokens) - 1):
pair_freqs[(tokens[i], tokens[i+1])] += freq
if not pair_freqs:
break
# Find most frequent pair
best_pair = max(pair_freqs, key=pair_freqs.get)
# Create new token for merge
new_token_id = len(vocab)
new_token_bytes = vocab[best_pair[0]] + vocab[best_pair[1]]
vocab[new_token_id] = new_token_bytes
merges[best_pair] = new_token_id
# Apply merge to all words
for word in word_tokens:
word_tokens[word] = apply_merge(word_tokens[word], best_pair, new_token_id)
return Tokenizer(vocab=vocab, merges=merges)
def apply_merge(tokens: List[int], pair: tuple, new_id: int) -> List[int]:
"""Replace all occurrences of pair in tokens with new_id."""
result = []
i = 0
while i < len(tokens):
if i < len(tokens)-1 and (tokens[i], tokens[i+1]) == pair:
result.append(new_id)
i += 2
else:
result.append(tokens[i])
i += 1
return result
BPE Encoding (Inference)
def encode(self, text: str) -> List[int]:
"""Encode text to token IDs using learned merges."""
# Apply pretokenization (same as training)
words = self.pretokenize(text)
all_tokens = []
for word in words:
# Start with byte-level tokens
tokens = list(word.encode())
# Apply merges in order of training (greedy, left-to-right)
while True:
# Find applicable merge with lowest training index
best = None
best_rank = float('inf')
for i in range(len(tokens) - 1):
pair = (tokens[i], tokens[i+1])
if pair in self.merges:
rank = self.merge_rank[pair] # training order
if rank < best_rank:
best_rank = rank
best = (i, pair)
if best is None:
break
i, pair = best
new_id = self.merges[pair]
tokens = tokens[:i] + [new_id] + tokens[i+2:]
all_tokens.extend(tokens)
return all_tokens
def decode(self, token_ids: List[int]) -> str:
"""Decode token IDs back to text."""
bytes_output = b"".join(self.vocab[id] for id in token_ids)
return bytes_output.decode('utf-8', errors='replace')
Special Tokens & Chat Templates
SPECIAL TOKENS (must be added after BPE training):
<|endoftext|> ← EOS/separator
<|pad|> ← padding
<|unk|> ← unknown (rarely used in BPE)
[INST], [/INST] ← instruction markers (Llama)
<|im_start|>, <|im_end|> ← ChatML format
CHAT TEMPLATE (converts messages to flat string):
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello!"}
]
ChatML format:
<|im_start|>system
You are helpful.<|im_end|>
<|im_start|>user
Hi<|im_end|>
<|im_start|>assistant
Hello!<|im_end|>
<|im_start|>assistant
[model continues here]
Project 2 — LoRA Trainer (Low-Rank Adaptation)
The Core Idea
FULL FINE-TUNING:
Update all W (d×d) weights — billions of parameters
Memory: 2× model size (model + gradients) + optimizer states (Adam: 3× params)
For 7B model: 7B × 4 bytes × (1 + 1 + 3) = ~140 GB minimum
LORA INSIGHT:
During fine-tuning, weight updates ΔW have low intrinsic rank
Instead of learning ΔW (d×d), learn: ΔW = A × B
where A is (d×r), B is (r×d), r << d
Full forward pass uses: W + AB
Only A and B are trained (r << d → 1000x fewer params)
NUMBERS:
d=4096, r=16
LoRA params per layer: 2 × 4096 × 16 = 131,072
Full params per layer: 4096 × 4096 = 16,777,216
Reduction: 128x per layer
Implementation
import torch
import torch.nn as nn
class LoRALinear(nn.Module):
def __init__(self, base_layer: nn.Linear, r: int = 16, alpha: float = 32,
dropout: float = 0.1):
super().__init__()
self.base_layer = base_layer
self.r = r
self.alpha = alpha
self.scaling = alpha / r # scale factor (merged into B at init)
d_in = base_layer.in_features
d_out = base_layer.out_features
# LoRA matrices
self.lora_A = nn.Parameter(torch.randn(r, d_in) * 0.02) # init: small random
self.lora_B = nn.Parameter(torch.zeros(d_out, r)) # init: ZERO → ΔW=0 at start
self.dropout = nn.Dropout(dropout)
# Freeze base layer
for param in self.base_layer.parameters():
param.requires_grad = False
def forward(self, x: torch.Tensor) -> torch.Tensor:
# Original: y = xW^T + b
base_output = self.base_layer(x)
# LoRA: y += x(AB)^T * scaling
lora_output = self.dropout(x) @ self.lora_A.T @ self.lora_B.T
return base_output + lora_output * self.scaling
def merge_weights(self):
"""Merge LoRA into base weights for inference (no overhead)."""
delta_W = (self.lora_B @ self.lora_A) * self.scaling
self.base_layer.weight.data += delta_W
self.merged = True
LoRA Application to Transformer
def apply_lora(model: nn.Module, lora_config: LoRAConfig) -> nn.Module:
"""Replace target modules with LoRA versions."""
for name, module in model.named_modules():
# Apply LoRA to attention projections: Q, K, V, O
if isinstance(module, nn.Linear) and any(
target in name for target in lora_config.target_modules
):
parent = get_parent_module(model, name)
attr = name.split('.')[-1]
lora_layer = LoRALinear(
base_layer=module,
r=lora_config.r,
alpha=lora_config.alpha,
dropout=lora_config.dropout
)
setattr(parent, attr, lora_layer)
return model
# Which modules to apply LoRA to:
# Typical: ["q_proj", "v_proj"] (Llama) or ["query", "value"] (BERT)
# Aggressive: all attention + FFN layers
# Conservative: query and value only
# Count trainable parameters
def count_params(model):
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
print(f"Trainable: {trainable:,} ({100*trainable/total:.2f}%)")
Training Loop
def train_lora(base_model, dataset, lora_config, training_args):
# Apply LoRA
model = apply_lora(base_model, lora_config)
# Only LoRA params in optimizer
optimizer = torch.optim.AdamW(
filter(lambda p: p.requires_grad, model.parameters()),
lr=training_args.learning_rate, # typically 2e-4 (higher than full FT)
weight_decay=0.01
)
# Gradient checkpointing + mixed precision (fits on single GPU)
model.gradient_checkpointing_enable()
scaler = torch.cuda.amp.GradScaler()
for step, batch in enumerate(dataloader):
with torch.cuda.amp.autocast():
outputs = model(**batch)
loss = outputs.loss
scaler.scale(loss).backward()
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(optimizer)
scaler.update()
optimizer.zero_grad()
# Save only LoRA weights (tiny: ~100MB for 7B model)
save_lora_weights(model, "lora_checkpoint/")
# Merge for deployment
merged_model = merge_lora_weights(model)
merged_model.save_pretrained("merged_model/")
Project 3 — PEFT Library (Parameter Efficient Fine-Tuning)
Taxonomy of PEFT Methods
PEFT METHODS
│
┌───────────────┼─────────────────┐
│ │ │
ADAPTERS SOFT PROMPTS SELECTIVE
│ │ FINE-TUNING
- LoRA - Prompt Tuning - BitFit
- Adapter layers - Prefix Tuning - Diff Pruning
- IA³ - P-Tuning v2 - Sparse FT
- AdaLoRA
- QLoRA
WHEN TO USE WHICH:
LoRA: Best all-around. Fast, effective, widely supported.
QLoRA: LoRA on quantized model. Fits 70B on single GPU.
Prefix Tuning: When you can't modify model architecture.
Prompt Tuning: Smallest overhead. Works for large models (>10B).
IA³: Extremely few params (3 vectors per layer). For massive models.
Adapter Layers
class AdapterLayer(nn.Module):
"""Houlsby adapter: down-project, nonlinearity, up-project."""
def __init__(self, d_model: int, adapter_size: int = 64):
super().__init__()
self.down = nn.Linear(d_model, adapter_size)
self.act = nn.GELU()
self.up = nn.Linear(adapter_size, d_model)
# Initialize near identity (small init → minimal disruption)
nn.init.normal_(self.down.weight, std=1e-3)
nn.init.normal_(self.up.weight, std=1e-3)
nn.init.zeros_(self.down.bias)
nn.init.zeros_(self.up.bias)
def forward(self, x):
return x + self.up(self.act(self.down(x))) # residual
# Insert adapter after attention and after FFN in each transformer block
Prefix Tuning
class PrefixTuning(nn.Module):
"""Prepend learned soft tokens to K and V in every attention layer."""
def __init__(self, n_layers, n_heads, d_head, prefix_length=20):
super().__init__()
self.prefix_length = prefix_length
# Learnable prefix embeddings (reparameterized through MLP for stability)
self.prefix_embeddings = nn.Parameter(
torch.randn(prefix_length, n_layers * 2 * n_heads * d_head)
)
self.prefix_mlp = nn.Sequential(
nn.Linear(n_layers * 2 * n_heads * d_head, 512),
nn.Tanh(),
nn.Linear(512, n_layers * 2 * n_heads * d_head)
)
def get_prefix_kv(self, batch_size):
# Transform prefix embeddings through MLP
prefix = self.prefix_mlp(self.prefix_embeddings)
# Reshape to (n_layers, 2, batch, n_heads, prefix_len, d_head)
prefix = prefix.view(self.prefix_length, n_layers, 2, n_heads, d_head)
prefix = prefix.permute(1, 2, ...).expand(batch_size, ...)
# Split into key and value prefixes per layer
return prefix[:, 0], prefix[:, 1] # (n_layers, batch, n_heads, prefix_len, d_head)
QLoRA
QLoRA = LoRA applied to a 4-bit quantized model
COMPONENTS:
1. NF4 quantization of base model (4-bit, see Quantization section)
2. Double quantization of scale factors (saves ~0.5 bits/param)
3. Paged optimizers (gradient checkpoints page to CPU RAM)
4. LoRA adapters trained in BF16 on top of frozen 4-bit weights
FORWARD PASS:
input (BF16) × W_nf4 (dequantized to BF16 just-in-time) + LoRA (BF16)
The NF4 base weights are NEVER updated
Only LoRA A and B are trained (BF16)
MEMORY CALCULATION (7B model, r=64):
Base model (NF4): 7B × 0.5 bytes = 3.5 GB
Scale factors (FP8): 7B/64 × 1 byte = 109 MB
LoRA params (BF16): ~160M × 2 bytes = 320 MB
Optimizer states: 320M × 8 bytes = 2.5 GB (Adam: m + v)
Activations (GC): ~1 GB
Total: ~7.5 GB → fits on single RTX 3090 (24 GB)
Project 4 — LLM Eval Harness
Architecture
EVAL HARNESS PIPELINE
─────────────────────
┌──────────────────────────────────────────────────────────┐
│ EVAL HARNESS │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ Dataset │ │ Model │ │ Metrics │ │
│ │ Loader │ │ Adapter │ │ Registry │ │
│ │ │ │ │ │ │ │
│ │ - MMLU │ │ - HF Model │ │ - Accuracy │ │
│ │ - GSM8K │ │ - OpenAI │ │ - Exact Match │ │
│ │ - HumanEval │ │ - Anthropic │ │ - BLEU/ROUGE │ │
│ │ - TruthfulQA│ │ - vLLM │ │ - Pass@k │ │
│ │ - HellaSwag │ │ - Local │ │ - Perplexity │ │
│ └──────┬──────┘ └──────┬──────┘ └────────┬────────┘ │
│ │ │ │ │
│ └────────────────┼───────────────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ TASK RUNNER │ │
│ │ │ │
│ │ For each sample: │ │
│ │ 1. Build prompt │ │
│ │ 2. Get model output│ │
│ │ 3. Score output │ │
│ │ 4. Aggregate │ │
│ └──────────┬──────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ RESULTS REPORTER │ │
│ │ JSON / leaderboard │ │
│ └─────────────────────┘ │
└──────────────────────────────────────────────────────────┘
Task Types
1. MULTIPLE CHOICE (MMLU, HellaSwag, ARC)
Evaluation approach: log-likelihood scoring
For each answer choice A, B, C, D:
score = log P(choice | question)
= sum of log P(token_i | question + preceding_choice_tokens)
Predict: argmax(scores)
Why log-likelihood instead of generation:
Deterministic, no sampling variance
Faster (no autoregressive generation)
Normalized by token count (avoid length bias)
2. OPEN-ENDED GENERATION (GSM8K, TruthfulQA, NaturalQuestions)
Generate answer, compare to reference:
- Exact match: normalize (lowercase, strip punctuation) → compare
- F1 token overlap: for QA tasks
- BLEU/ROUGE: for generation quality
GSM8K: extract final numerical answer from chain-of-thought
Pattern: r'\d+\.?\d*$' on last line
3. CODE GENERATION (HumanEval, MBPP)
pass@k metric:
Generate k completions per problem
Problem is solved if ANY completion passes all unit tests
pass@k = 1 - C(n-c, k) / C(n, k)
where: n = total generations, c = correct generations
Estimate with n=20, compute pass@1, pass@5, pass@10
Execution:
Sandbox code + unit tests → run → check all tests pass
4. PERPLEXITY (language modeling quality)
PPL = exp(-1/N × Σ log P(token_i | context))
Lower = better
Rolling window for long documents:
stride = 512, context = 1024
Score only the new tokens (last stride tokens)
Average across document
Implementation
class EvalHarness:
def __init__(self, model: ModelAdapter, tasks: List[Task]):
self.model = model
self.tasks = tasks
def evaluate(self) -> EvalResults:
results = {}
for task in self.tasks:
dataset = task.load_dataset()
scores = []
for batch in DataLoader(dataset, batch_size=task.batch_size):
if task.eval_type == "multiple_choice":
batch_scores = self._eval_mc(task, batch)
elif task.eval_type == "generation":
batch_scores = self._eval_generation(task, batch)
elif task.eval_type == "code":
batch_scores = self._eval_code(task, batch)
scores.extend(batch_scores)
results[task.name] = {
"score": np.mean(scores),
"stderr": np.std(scores) / np.sqrt(len(scores)),
"n_samples": len(scores)
}
return EvalResults(results)
def _eval_mc(self, task, batch) -> List[float]:
"""Multiple choice via log-likelihood scoring."""
correct = []
for item in batch:
ctx = task.build_context(item)
choices = item["choices"]
gold = item["gold"]
# Get log-likelihood for each choice
lls = []
for choice in choices:
ll = self.model.loglikelihood(ctx, choice)
lls.append(ll)
# Normalize by continuation length (per-token)
normalized = [ll / len(enc) for ll, enc in zip(lls, encoded_choices)]
pred = np.argmax(normalized)
correct.append(int(pred == gold))
return correct
Project 5 — Model Merger (SLERP / Model Soups)
Why Merge Models?
Scenario 1: Task arithmetic
Base model + code model + math model → merge → strong multi-task model
No additional training required
Scenario 2: Ensemble on budget
Average N fine-tuned models → better than any single model
Only 1 model at inference (averaged weights)
Scenario 3: Capability interpolation
Model A: strong at reasoning but verbose
Model B: concise but weaker reasoning
Merge at λ=0.5 → balanced model
Model Soup (Simple Averaging)
def model_soup(model_paths: List[str], weights: List[float] = None) -> Model:
"""Uniform or weighted average of model checkpoints."""
if weights is None:
weights = [1.0 / len(model_paths)] * len(model_paths)
models = [load_model(p) for p in model_paths]
base = models[0]
with torch.no_grad():
for name, param in base.named_parameters():
param.data = sum(
w * m.state_dict()[name].float()
for m, w in zip(models, weights)
)
return base
# Requirements for averaging to work:
# - Models must share SAME architecture (identical layer shapes)
# - Models must be in SAME loss basin (fine-tuned from same base)
# - Averaging interpolates between their loss basins
SLERP (Spherical Linear Interpolation)
WHY SLERP OVER LERP:
Weights lie on high-dimensional sphere (approximately)
Linear interpolation (LERP): shorter path through interior
Spherical interpolation (SLERP): follows arc on surface
SLERP maintains "norm" of weights better → better preserved features
FORMULA:
SLERP(w₁, w₂, t) = sin((1-t)θ)/sin(θ) × w₁ + sin(tθ)/sin(θ) × w₂
where θ = arccos(w₁·w₂ / (|w₁||w₂|))
t ∈ [0,1]: t=0 → w₁, t=1 → w₂
IMPLEMENTATION:
```python
def slerp(w1: torch.Tensor, w2: torch.Tensor, t: float) -> torch.Tensor:
"""Spherical linear interpolation between two weight tensors."""
w1_flat = w1.float().view(-1)
w2_flat = w2.float().view(-1)
# Normalize
w1_norm = F.normalize(w1_flat, dim=0)
w2_norm = F.normalize(w2_flat, dim=0)
# Angle between
dot = torch.clamp(torch.dot(w1_norm, w2_norm), -1.0, 1.0)
theta = torch.acos(dot)
if theta.abs() < 1e-6:
# Nearly parallel — fall back to linear interpolation
return (1-t) * w1 + t * w2
# SLERP formula
sin_theta = torch.sin(theta)
w1_coeff = torch.sin((1-t) * theta) / sin_theta
w2_coeff = torch.sin(t * theta) / sin_theta
result = w1_coeff * w1_flat + w2_coeff * w2_flat
# Rescale to original norm (interpolated between norms)
target_norm = (1-t) * w1_flat.norm() + t * w2_flat.norm()
result = F.normalize(result) * target_norm
return result.view(w1.shape).to(w1.dtype)
DARE + TIES Merging (Advanced)
TIES (Trim, Elect, Disjoint Merge):
Problem: averaging causes interference from conflicting task vectors
Step 1: TRIM
Compute task vectors: τᵢ = θᵢ - θ_base
Keep only top-k% of values by magnitude, zero rest
Rationale: small deltas → noise, large deltas → meaningful updates
Step 2: ELECT
For each parameter: count sign agreement across models
Elect majority sign (resolve conflicts)
Step 3: MERGE
Average only parameters where sign agrees with elected sign
θ_merged = θ_base + mean(τᵢ where sign(τᵢ) == elected_sign)
DARE (Drop and REscale):
Randomly drop task vector parameters (p=0.9 typical)
Rescale remaining by 1/(1-p) to preserve magnitude
Reduces interference between task vectors
Works well when p is high (90% dropout)
Project 6 — Embedding Model
Architecture
TEXT → EMBEDDING VECTOR (fixed-size, semantic representation)
BACKBONE: BERT-style encoder (bidirectional transformer)
Input: [CLS] token1 token2 ... tokenN [SEP]
Process: full bidirectional attention (all tokens see all)
Output: hidden states for all positions
POOLING STRATEGIES:
CLS pooling: use hidden state of [CLS] token
Mean pooling: average all token hidden states (usually better)
Max pooling: element-wise max across tokens
Mean pooling (weighted by attention mask):
token_embeddings = model_output.last_hidden_state # (B, T, D)
attention_mask = inputs['attention_mask'] # (B, T)
mask_expanded = attention_mask.unsqueeze(-1).expand_as(token_embeddings)
sum_embeddings = (token_embeddings * mask_expanded).sum(dim=1)
sum_mask = mask_expanded.sum(dim=1).clamp(min=1e-9)
embeddings = sum_embeddings / sum_mask # (B, D)
Normalize: F.normalize(embeddings, p=2, dim=-1) ← unit sphere
Training: Contrastive Learning (SimCSE / E5)
CONTRASTIVE LOSS (InfoNCE / NT-Xent):
Given: anchor sentence a, positive p, negatives n₁...nₖ
Goal: embed(a) close to embed(p), far from embed(nᵢ)
loss = -log [exp(sim(a,p)/τ) / (exp(sim(a,p)/τ) + Σ exp(sim(a,nᵢ)/τ))]
τ = temperature (0.05 typical)
sim = cosine similarity
DATA SOURCES:
Positive pairs:
- (question, answer) from QA datasets
- (premise, entailed_hypothesis) from NLI
- (query, relevant_doc) from MS-MARCO
- (sentence, paraphrase)
- (sentence, dropout-augmented same sentence) ← SimCSE unsupervised
Negative pairs:
- Random from batch (in-batch negatives, efficient)
- Hard negatives: similar-looking but semantically different
- Mined negatives: BM25 top results that are NOT relevant
IN-BATCH NEGATIVES:
Batch of (anchor, positive) pairs
Use all other positives in batch as negatives for each anchor
Batch size 256 → 255 negatives per sample (large effective batch)
scores = anchor_embs @ positive_embs.T # (B, B)
labels = torch.arange(B) # diagonal is positive
loss = CrossEntropyLoss(scores / temp, labels)
Project 7 — Logit Processor
Logit Processor Chain
Raw logits from model
│
▼
┌───────────────────────────────────────────┐
│ LOGIT PROCESSOR CHAIN │
│ │
│ 1. TemperatureProcessor │
│ logits = logits / temperature │
│ │
│ 2. RepetitionPenaltyProcessor │
│ for tok in generated_tokens: │
│ if logits[tok] < 0: │
│ logits[tok] *= penalty │
│ else: │
│ logits[tok] /= penalty │
│ │
│ 3. TopKProcessor │
│ Remove all tokens outside top-k │
│ Set their logits to -inf │
│ │
│ 4. TopPProcessor (nucleus sampling) │
│ Sort by probability descending │
│ Find smallest set summing to > p │
│ Remove tokens outside that set │
│ │
│ 5. MinLengthProcessor │
│ If len(generated) < min_length: │
│ logits[EOS] = -inf │
│ │
│ 6. BadWordsProcessor │
│ logits[banned_token_ids] = -inf │
└───────────────────────────────────────────┘
│
▼
softmax → sample
Implementation
class LogitProcessorList:
def __init__(self, processors: List[LogitProcessor]):
self.processors = processors
def __call__(self, input_ids: torch.Tensor, scores: torch.Tensor) -> torch.Tensor:
for processor in self.processors:
scores = processor(input_ids, scores)
return scores
class TopPLogitWarper:
def __init__(self, top_p: float, min_tokens_to_keep: int = 1):
self.top_p = top_p
self.min_tokens = min_tokens_to_keep
def __call__(self, input_ids, scores):
sorted_logits, sorted_indices = torch.sort(scores, descending=True)
cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
# Remove tokens with cumulative prob above threshold (keep first token above)
sorted_remove = cumulative_probs - F.softmax(sorted_logits, dim=-1) > self.top_p
sorted_remove[..., :self.min_tokens] = False # keep at least min_tokens
# Scatter back to original indexing
indices_to_remove = sorted_remove.scatter(-1, sorted_indices, sorted_remove)
scores = scores.masked_fill(indices_to_remove, float('-inf'))
return scores
Project 8 — Guardrails System (Input/Output Filtering)
Architecture
GUARDRAILS PIPELINE
User Input
│
▼
┌─────────────────────────────────────────────┐
│ INPUT GUARDRAILS │
│ │
│ 1. PII Detector │
│ Regex + NER: SSN, credit cards, │
│ emails, phone numbers │
│ Action: redact or reject │
│ │
│ 2. Prompt Injection Detector │
│ Patterns: "ignore previous instructions"│
│ "you are now DAN", role-play bypasses │
│ ML classifier on input │
│ │
│ 3. Topic Filter │
│ Classifier: allowed vs blocked topics │
│ (violence, NSFW, medical, legal) │
│ │
│ 4. Language Detector │
│ Reject if not in supported_languages │
└─────────────────┬───────────────────────────┘
│ cleaned input
▼
LLM Generation
│
▼
┌─────────────────────────────────────────────┐
│ OUTPUT GUARDRAILS │
│ │
│ 1. Toxicity Classifier │
│ Score: hate speech, harassment, etc. │
│ Threshold-based: score > 0.8 → reject │
│ │
│ 2. Hallucination Detector │
│ Ground output against retrieved context │
│ NLI: does output contradict sources? │
│ │
│ 3. PII in Output │
│ Same as input PII detection │
│ Redact before returning to user │
│ │
│ 4. Brand Safety │
│ Custom word lists, competitor mentions │
└─────────────────┬───────────────────────────┘
│ safe output
▼
Final Response
How These 12 Projects Form One System
┌──────────────────────────────────────────────────┐
│ MINI HUGGING FACE │
│ │
Raw Text ──────▶ │ Tokenizer (BPE) ──▶ token_ids │
│ │ │
Model Weights ──▶ │ PEFT / LoRA / QLoRA ──▶ adapted model │
│ │ │
Adapted Model ──▶ │ Eval Harness ──▶ benchmark scores │
│ │ │
Multiple Models ▶ │ Model Merger (SLERP) ──▶ merged model │
│ │ │
│ Embedding Model ──▶ semantic vectors │
│ │ │
At Inference: │ Logit Processor ──▶ sampling control │
│ Guardrails ──▶ safety filtering │
│ Prompt Cache ──▶ KV reuse │
│ │
Audio Input ────▶ │ AST → Whisper → ASR transcript │
Text ──────────▶ │ TTS Pipeline ──▶ audio output │
└──────────────────────────────────────────────────┘
Production deployment checklist derived from this system:
✓ Tokenizer serialized and versioned alongside model
✓ PEFT adapters can be hot-swapped per request (LoRA multiplexing)
✓ Eval harness runs on every checkpoint (regression detection)
✓ Guardrails run async parallel to generation when possible
✓ Embedding model serves as backbone for retrieval and routing
✓ Multimodal: ASR converts voice input before text processing
Mini Research Lab — E2E Architecture Reference
Projects: RLHF Pipeline (PPO) · DPO Loss Function · Model Distillation · Synthetic Data Generator · MoE Routing Layer · Distributed Training (FSDP/Tensor Parallel) · State Space Model (Mamba) · Interpretability (SAE) · Neural Architecture Search · Data Curation Pipeline · Recommendation System (Two-Tower) · Adversarial Attack Generator · Multi-modal Projector (CLIP) · AI Gateway
What You're Building
The research infrastructure stack: how models are aligned to human preferences, how knowledge is transferred between models, how data is curated and generated, and how models are scaled and analyzed. This is the work that happens between pretraining and deployment — the layer that separates a capable model from a useful, safe, production model.
Project 1 — RLHF Pipeline (PPO Implementation)
Full RLHF Architecture
STAGE 1: SUPERVISED FINE-TUNING (SFT)
Base model → train on high-quality demonstrations
Result: SFT model (knows desired output format and style)
STAGE 2: REWARD MODEL TRAINING
SFT model → generate multiple responses per prompt
Humans rank responses: A > B, B > C, etc.
Train reward model: predict which response humans prefer
STAGE 3: PPO FINE-TUNING (RL)
Use reward model as environment
Update SFT model via PPO to maximize reward
KL penalty prevents drifting too far from SFT model
Result: RLHF-tuned model
ALL FOUR MODELS IN MEMORY DURING PPO:
1. Actor (policy model being trained)
2. Reference model (frozen SFT model, for KL)
3. Critic (value network, estimates return)
4. Reward model (frozen, provides reward signal)
Reward Model Training
class RewardModel(nn.Module):
def __init__(self, base_model):
super().__init__()
self.transformer = base_model
self.reward_head = nn.Linear(base_model.config.d_model, 1)
def forward(self, input_ids, attention_mask):
outputs = self.transformer(input_ids, attention_mask=attention_mask)
last_hidden = outputs.last_hidden_state[:, -1, :] # last token
reward = self.reward_head(last_hidden).squeeze(-1)
return reward
def reward_model_loss(reward_model, chosen_ids, rejected_ids, chosen_mask, rejected_mask):
"""Bradley-Terry pairwise ranking loss."""
r_chosen = reward_model(chosen_ids, chosen_mask) # scalar per sample
r_rejected = reward_model(rejected_ids, rejected_mask)
# Loss: chosen should have higher reward than rejected
# -log sigmoid(r_chosen - r_rejected)
loss = -F.logsigmoid(r_chosen - r_rejected).mean()
# Accuracy: fraction of pairs where chosen > rejected
accuracy = (r_chosen > r_rejected).float().mean()
return loss, accuracy
# Training data format: (prompt, chosen_response, rejected_response)
# Source: human preference labels (Anthropic HH, OpenAI Instruct comparisons)
PPO Training Loop
class PPOTrainer:
def __init__(self, actor, ref_model, critic, reward_model, tokenizer):
self.actor = actor # policy being trained
self.ref = ref_model # frozen SFT model
self.critic = critic # value function
self.rm = reward_model # frozen reward model
self.actor_optimizer = AdamW(actor.parameters(), lr=1e-5)
self.critic_optimizer = AdamW(critic.parameters(), lr=1e-5)
def collect_rollouts(self, prompts: List[str]) -> RolloutBuffer:
"""Generate responses and collect data for training."""
rollouts = []
for prompt in prompts:
# Actor generates response
prompt_ids = tokenize(prompt)
with torch.no_grad():
response_ids = self.actor.generate(prompt_ids, max_new_tokens=256)
new_tokens = response_ids[len(prompt_ids):]
# Get reward from reward model
full_ids = torch.cat([prompt_ids, response_ids])
reward = self.rm(full_ids).item()
# Get log probs from actor and reference
actor_logprobs = get_log_probs(self.actor, prompt_ids, new_tokens)
ref_logprobs = get_log_probs(self.ref, prompt_ids, new_tokens)
# KL penalty per token
kl = actor_logprobs - ref_logprobs # (n_new_tokens,)
kl_reward = reward - self.kl_coef * kl.sum() # scalar
# Value estimate (from critic)
value = self.critic(full_ids).item()
rollouts.append(Rollout(
prompt_ids=prompt_ids,
response_ids=response_ids,
reward=kl_reward,
actor_logprobs=actor_logprobs,
ref_logprobs=ref_logprobs,
value=value
))
return rollouts
def compute_advantages(self, rollouts):
"""GAE (Generalized Advantage Estimation)."""
for rollout in rollouts:
# Simple version: advantage = reward - value baseline
rollout.advantage = rollout.reward - rollout.value
rollout.returns = rollout.reward # (single-step; normally bootstrapped)
def train_step(self, rollouts):
"""PPO clip update."""
self.compute_advantages(rollouts)
for epoch in range(self.ppo_epochs): # typically 4 epochs
for batch in batch_iter(rollouts, self.batch_size):
# Recompute current log probs
current_logprobs = get_log_probs(self.actor, batch.prompt_ids, batch.response_ids)
# Probability ratio (importance sampling)
ratio = torch.exp(current_logprobs - batch.actor_logprobs)
# PPO clipped objective
adv = batch.advantage
obj_unclipped = ratio * adv
obj_clipped = torch.clamp(ratio, 1-self.clip_eps, 1+self.clip_eps) * adv
actor_loss = -torch.min(obj_unclipped, obj_clipped).mean()
# Value function loss
current_values = self.critic(batch.full_ids)
critic_loss = F.mse_loss(current_values, batch.returns)
# Entropy bonus (encourages exploration)
entropy = compute_entropy(current_logprobs)
total_loss = actor_loss + 0.5 * critic_loss - 0.01 * entropy
self.actor_optimizer.zero_grad()
self.critic_optimizer.zero_grad()
total_loss.backward()
torch.nn.utils.clip_grad_norm_(self.actor.parameters(), 1.0)
self.actor_optimizer.step()
self.critic_optimizer.step()
Project 2 — DPO (Direct Preference Optimization)
DPO vs PPO
PPO ISSUES:
✗ 4 models in memory simultaneously
✗ Reward model training step
✗ Complex PPO hyperparameter tuning
✗ Training instability
✗ Reward hacking (model exploits reward model weaknesses)
DPO INSIGHT:
The optimal policy under KL-constrained RL has a closed form:
π*(y|x) ∝ π_ref(y|x) × exp(r(x,y) / β)
Rearranging: r(x,y) = β × log[π*(y|x)/π_ref(y|x)] + Z(x)
Substitute into Bradley-Terry preference model:
P(y_w ≻ y_l | x) = σ(r(x,y_w) - r(x,y_l))
= σ(β × log[π_θ(y_w|x)/π_ref(y_w|x)]
- β × log[π_θ(y_l|x)/π_ref(y_l|x)])
→ The reward MODEL is IMPLICIT in the policy ratio!
Train policy directly on preference data, no explicit reward model needed
RESULT: Same 3 stages → SFT → DPO (replaces RM training + PPO)
Only 2 models in memory: policy + frozen reference
DPO Loss Implementation
def dpo_loss(
policy_model,
reference_model,
chosen_ids: torch.Tensor, # (B, T)
rejected_ids: torch.Tensor, # (B, T)
chosen_mask: torch.Tensor, # (B, T)
rejected_mask:torch.Tensor, # (B, T)
prompt_length: int, # to separate prompt from response
beta: float = 0.1,
) -> torch.Tensor:
"""DPO loss function."""
def get_logprobs(model, input_ids, attention_mask):
"""Compute per-token log probabilities for the completion."""
with torch.no_grad() if model is reference_model else contextlib.nullcontext():
logits = model(input_ids, attention_mask=attention_mask).logits
# Shift for next-token prediction
shift_logits = logits[:, :-1, :] # (B, T-1, V)
shift_labels = input_ids[:, 1:] # (B, T-1)
# Get log prob of actual tokens
logprobs = F.log_softmax(shift_logits, dim=-1)
token_logprobs = logprobs.gather(-1, shift_labels.unsqueeze(-1)).squeeze(-1) # (B, T-1)
# Mask out prompt tokens (only score completion)
completion_mask = attention_mask[:, 1:].clone()
completion_mask[:, :prompt_length-1] = 0
# Sum log probs over completion tokens
return (token_logprobs * completion_mask).sum(-1) # (B,)
# Log probs under policy (trained) and reference (frozen)
policy_chosen_logps = get_logprobs(policy_model, chosen_ids, chosen_mask)
policy_rejected_logps = get_logprobs(policy_model, rejected_ids, rejected_mask)
ref_chosen_logps = get_logprobs(reference_model, chosen_ids, chosen_mask)
ref_rejected_logps = get_logprobs(reference_model, rejected_ids, rejected_mask)
# Log ratios (policy vs reference)
chosen_logratios = policy_chosen_logps - ref_chosen_logps # (B,)
rejected_logratios = policy_rejected_logps - ref_rejected_logps # (B,)
# DPO loss: push chosen logratios higher, rejected lower
loss = -F.logsigmoid(beta * (chosen_logratios - rejected_logratios)).mean()
# Diagnostics
chosen_rewards = beta * chosen_logratios.detach()
rejected_rewards = beta * rejected_logratios.detach()
reward_accuracy = (chosen_rewards > rejected_rewards).float().mean()
reward_margin = (chosen_rewards - rejected_rewards).mean()
return loss, {
"reward_accuracy": reward_accuracy,
"reward_margin": reward_margin,
"chosen_rewards": chosen_rewards.mean(),
"rejected_rewards": rejected_rewards.mean()
}
DPO Variants
STANDARD DPO (Rafailov et al 2023):
Loss = -log σ(β × (log π_θ(y_w)/π_ref(y_w) - log π_θ(y_l)/π_ref(y_l)))
IPO (Identity Preference Optimization):
Loss = (log π_θ(y_w)/π_ref(y_w) - log π_θ(y_l)/π_ref(y_l) - 1/2β)²
More stable, doesn't have σ saturation issue
KTO (Kahneman-Tversky Optimization):
Uses unpaired preference data (just "good" or "bad" labels)
No need for paired (chosen, rejected) comparisons
More data-efficient in practice
ORPO (Odds Ratio Preference Optimization):
Doesn't need reference model at all
Combines SFT and preference loss in single stage
loss = -log P(y|x) - λ × log σ(log odds(y_w) - log odds(y_l))
Project 3 — Model Distillation Pipeline
Knowledge Distillation Architecture
TEACHER: Large, accurate model (e.g., GPT-4, Llama-3-70B)
STUDENT: Small, fast model (e.g., Llama-3-1B, custom architecture)
GOAL: Student learns to mimic teacher's behavior
Student accuracy >> same-size model trained from scratch
THREE TYPES OF DISTILLATION:
1. Black-box: Only teacher outputs (API access)
2. Logit-level: Teacher's full probability distribution
3. Feature-level: Match internal representations
Logit Distillation (Hinton et al)
def distillation_loss(
student_logits: torch.Tensor, # (B, T, V)
teacher_logits: torch.Tensor, # (B, T, V) — from frozen teacher
labels: torch.Tensor, # (B, T) — ground truth tokens
temperature: float = 4.0,
alpha: float = 0.7, # weight: 1=pure distill, 0=pure CE
) -> torch.Tensor:
"""Combined hard-label CE and soft-label KL divergence."""
# Hard-label loss (student vs ground truth)
hard_loss = F.cross_entropy(
student_logits.view(-1, student_logits.size(-1)),
labels.view(-1)
)
# Soft-label distillation loss
# Temperature scaling: higher T → softer probabilities → more info in distribution
student_soft = F.log_softmax(student_logits / temperature, dim=-1)
teacher_soft = F.softmax(teacher_logits / temperature, dim=-1)
# KL divergence: how different is student distribution from teacher
# KL(teacher || student) = Σ teacher × log(teacher/student)
soft_loss = F.kl_div(student_soft, teacher_soft, reduction='batchmean')
soft_loss *= temperature ** 2 # scale factor from Hinton et al
return alpha * soft_loss + (1 - alpha) * hard_loss
# Training loop:
# 1. Load frozen teacher, trainable student
# 2. Forward both on same inputs
# 3. Compute distillation loss
# 4. Update only student parameters
Sequence-Level Distillation (No Teacher Access Needed)
OFFLINE DISTILLATION:
1. Use teacher to generate responses for all training prompts
2. Store (prompt, teacher_response) pairs as training data
3. Train student with standard SFT on this data
Advantage: No need to run teacher during student training
Use case: Teacher is commercial API (expensive), student is open model
DATA GENERATION PIPELINE:
prompts = load_dataset("openhermes", split="train")
for batch in prompts:
responses = teacher_api.batch_generate(
batch,
temperature=1.0, # sample diversity
n=1, # one response per prompt
max_tokens=2048
)
save_to_dataset(batch, responses)
# Result: (prompt, response) pairs matching teacher's style
train_student_sft(student, synthetic_dataset)
Task-Specific Distillation
SPECULATIVE DECODING DISTILLATION:
Train draft model to match target model's next-token distribution
Loss: minimize KL(target_distribution || draft_distribution) per position
Result: high acceptance rate in speculative decoding
LAYER-LEVEL DISTILLATION:
Map student layer i → teacher layer j
MSE loss on attention patterns or hidden states
Enables deeper compression
ATTENTION TRANSFER:
student_attn = student_layer.attention_probs # (B, H, T, T)
teacher_attn = teacher_layer.attention_probs # (B, H', T, T)
# Match averaged attention patterns
student_avg = student_attn.mean(1) # avg over heads
teacher_avg = teacher_attn.mean(1)
attn_loss = F.mse_loss(student_avg, teacher_avg.detach())
Project 4 — Synthetic Data Generator
Architecture
┌──────────────────────────────────────────────────────────────┐
│ SYNTHETIC DATA GENERATOR │
│ │
│ PIPELINE: │
│ │
│ 1. SEED DATA │
│ Real samples from domain │
│ Few high-quality examples │
│ Task description │
│ │ │
│ ▼ │
│ 2. GENERATION │
│ Template-based generation │
│ LLM-based generation (Self-Instruct, Magpie) │
│ Constrained generation (entity slots) │
│ │ │
│ ▼ │
│ 3. QUALITY FILTERING │
│ Perplexity filter (too easy/hard) │
│ Reward model scoring │
│ LLM-as-judge evaluation │
│ Deduplication (MinHash) │
│ │ │
│ ▼ │
│ 4. POST-PROCESSING │
│ Format standardization │
│ PII removal │
│ Train/val/test split │
└──────────────────────────────────────────────────────────────┘
Self-Instruct Algorithm
class SelfInstructGenerator:
def __init__(self, generator_llm, judge_llm, seed_tasks: List[dict]):
self.generator = generator_llm
self.judge = judge_llm
self.task_pool = seed_tasks # initial high-quality tasks
def generate_new_task(self) -> dict:
# Sample 8 existing tasks as few-shot examples
examples = random.sample(self.task_pool, min(8, len(self.task_pool)))
prompt = self._build_generation_prompt(examples)
# Generate new instruction
new_instruction = self.generator.generate(prompt, temperature=0.9)
# Generate input/output for the instruction
input_prompt = f"Given the instruction: {new_instruction}\nGenerate a good input:"
synthetic_input = self.generator.generate(input_prompt)
output_prompt = f"Instruction: {new_instruction}\nInput: {synthetic_input}\nOutput:"
synthetic_output = self.generator.generate(output_prompt, temperature=0.0)
return {
"instruction": new_instruction,
"input": synthetic_input,
"output": synthetic_output
}
def filter_task(self, task: dict) -> bool:
"""Quality gate using LLM-as-judge."""
# ROUGE deduplication — reject if too similar to existing
for existing in self.task_pool:
rouge = compute_rouge_l(task["instruction"], existing["instruction"])
if rouge > 0.7:
return False
# LLM quality judge
judge_prompt = f"""Rate this instruction-following sample on a 1-5 scale:
Instruction: {task['instruction']}
Input: {task['input']}
Output: {task['output']}
Rate: (1=poor, 5=excellent). Only output the number."""
score = float(self.judge.generate(judge_prompt))
return score >= 4.0
def run(self, target_size: int = 50000):
while len(self.task_pool) < target_size:
task = self.generate_new_task()
if self.filter_task(task):
self.task_pool.append(task)
return self.task_pool
Privacy-Preserving Synthetic Data (Healthcare)
CLINICAL NOTE SYNTHESIS:
Problem: Real patient data can't be used for training
Solution: Generate realistic synthetic notes with no real PII
PIPELINE:
1. Extract schema from de-identified samples (structure only)
{patient_age, diagnosis_codes, symptoms, medications, ...}
2. Sample realistic field values:
age: Gaussian(65, 15) clipped to [0, 100]
diagnosis: sample ICD-10 codes by prevalence
medications: conditional on diagnosis (drug-disease correlation)
3. Fill template with sampled values:
"Patient is a {age}-year-old {sex} presenting with {symptoms}..."
4. LLM fluency pass:
"Rewrite this clinical note to be natural and professional: {template}"
5. NER verification: confirm no real names/SSNs leaked through
6. Medical validity check: LLM-as-judge for clinical plausibility
DIFFERENTIAL PRIVACY:
If training on real data, use DP-SGD:
- Clip per-sample gradients: ||g|| ≤ C
- Add Gaussian noise: g += N(0, σ²C²I)
- Privacy accounting: (ε, δ)-DP guarantee
Tradeoff: privacy ε vs model utility
Project 5 — MoE Routing Layer (Mixture of Experts)
Architecture
STANDARD FFN:
output = FFN(x) = W₂ × GELU(W₁ × x)
All tokens through same network
MOE FFN (Sparse):
K "expert" FFNs, each token routed to top-2
Total params: K × FFN_params
Active params per token: 2 × FFN_params (same as 1 FFN)
output = Σᵢ gate_i(x) × Expert_i(x) where i ∈ top_2_experts(x)
BENEFITS:
Capacity scales with K (more experts = more knowledge)
Compute stays constant (only 2 experts per token)
Mistral MoE: 8 experts, top-2 → 46.7B params, 12.9B active
Router Implementation
class MoELayer(nn.Module):
def __init__(self, d_model: int, d_ff: int, n_experts: int = 8, top_k: int = 2):
super().__init__()
self.n_experts = n_experts
self.top_k = top_k
# Routing network
self.router = nn.Linear(d_model, n_experts, bias=False)
# Expert FFNs (each is independent FFN)
self.experts = nn.ModuleList([
FeedForward(d_model, d_ff) for _ in range(n_experts)
])
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
x: (batch, seq_len, d_model)
"""
B, T, D = x.shape
x_flat = x.view(-1, D) # (B*T, D)
# Compute routing weights
router_logits = self.router(x_flat) # (B*T, n_experts)
router_probs = F.softmax(router_logits, dim=-1) # (B*T, n_experts)
# Select top-k experts per token
top_k_probs, top_k_indices = router_probs.topk(self.top_k, dim=-1)
# top_k_probs: (B*T, top_k)
# top_k_indices: (B*T, top_k)
# Renormalize top-k weights
top_k_probs = top_k_probs / top_k_probs.sum(dim=-1, keepdim=True)
# Compute expert outputs (sparse dispatch)
output = torch.zeros_like(x_flat)
for expert_idx in range(self.n_experts):
# Find tokens routed to this expert
token_mask = (top_k_indices == expert_idx).any(dim=-1) # (B*T,)
if not token_mask.any():
continue
# Get weights for these tokens
expert_positions = (top_k_indices[token_mask] == expert_idx).float() # (n_tokens, top_k)
weights = (top_k_probs[token_mask] * expert_positions).sum(-1) # (n_tokens,)
# Run expert on selected tokens
expert_output = self.experts[expert_idx](x_flat[token_mask]) # (n_tokens, D)
output[token_mask] += weights.unsqueeze(-1) * expert_output
return output.view(B, T, D)
Load Balancing Loss
def auxiliary_load_balancing_loss(router_probs: torch.Tensor, n_experts: int) -> torch.Tensor:
"""
Prevent all tokens routing to same expert.
Loss = n_experts × Σᵢ (fraction_of_tokens_i × mean_routing_prob_i)
"""
B_T = router_probs.size(0)
# Fraction of tokens per expert (hard assignment)
top_1 = router_probs.argmax(dim=-1) # (B*T,)
tokens_per_expert = torch.bincount(top_1, minlength=n_experts).float() / B_T # (n_experts,)
# Mean routing probability per expert (soft)
mean_prob_per_expert = router_probs.mean(dim=0) # (n_experts,)
# Auxiliary loss: encourages uniform distribution
aux_loss = n_experts * torch.dot(tokens_per_expert, mean_prob_per_expert)
return aux_loss # add to main training loss with small coefficient (1e-2)
Project 6 — Distributed Training (FSDP / Tensor Parallelism)
Distributed Strategy Overview
MODEL PARALLELISM STRATEGIES:
1. DATA PARALLELISM (DDP)
- Each GPU: full model copy
- Each GPU: different batch
- Sync: all-reduce gradients after backward
- Limit: model must fit in 1 GPU
- Efficiency: linear scaling up to communication overhead
2. FULLY SHARDED DATA PARALLELISM (FSDP)
- Shard model parameters, gradients, optimizer states across GPUs
- Each GPU: 1/N of each tensor
- Communication: all-gather before forward, reduce-scatter after backward
- Enables: models too large for 1 GPU
- Memory: nearly linear reduction with N GPUs
3. TENSOR PARALLELISM (TP)
- Split individual matrices across GPUs
- Matrix multiply distributed: partial results merged via all-reduce
- Used within a single node (high bandwidth NVLink)
4. PIPELINE PARALLELISM (PP)
- Split model layers across GPUs (GPU1: layers 1-10, GPU2: 11-20...)
- Micro-batching to fill pipeline and reduce bubble time
- Used across nodes (slower interconnect OK)
HYBRID (3D Parallelism, Megatron-LM):
DP × TP × PP for 1000+ GPU training
FSDP Implementation Sketch
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
from torch.distributed.fsdp.wrap import transformer_auto_wrap_policy
def train_with_fsdp():
# Initialize process group
dist.init_process_group(backend='nccl')
rank = dist.get_rank()
local_rank = int(os.environ['LOCAL_RANK'])
torch.cuda.set_device(local_rank)
# Load model on CPU first (avoid GPU OOM)
model = LlamaForCausalLM.from_pretrained("meta-llama/Llama-3-70b")
# Wrap with FSDP
# transformer_auto_wrap_policy: shard at TransformerBlock boundaries
model = FSDP(
model,
auto_wrap_policy=transformer_auto_wrap_policy,
mixed_precision=MixedPrecision(
param_dtype=torch.bfloat16,
reduce_dtype=torch.float32,
buffer_dtype=torch.bfloat16
),
sharding_strategy=ShardingStrategy.FULL_SHARD, # shard params + grads + optimizer
device_id=local_rank,
cpu_offload=CPUOffload(offload_params=False), # keep params on GPU
)
optimizer = AdamW(model.parameters(), lr=1e-5)
for batch in dataloader:
batch = batch.to(local_rank)
output = model(**batch)
loss = output.loss
loss.backward() # FSDP handles gradient collection
optimizer.step() # each rank updates its shard
optimizer.zero_grad()
# Communication pattern per step:
# Forward: all-gather full params → compute → free non-local shards
# Backward: all-gather full params → compute grad → reduce-scatter gradients
# Optimizer: each rank updates its own parameter shard
Tensor Parallelism (Megatron-LM Style)
COLUMN PARALLEL LINEAR (split output dimension):
W: (d_in, d_out) → GPU_i gets W[:, d_out/N × i : d_out/N × (i+1)]
y_partial = x @ W_i ← each GPU computes partial output
(no communication needed in forward if next layer is row parallel)
ROW PARALLEL LINEAR (split input dimension):
W: (d_in, d_out) → GPU_i gets W[d_in/N × i : d_in/N × (i+1), :]
x_partial already on GPU_i from column parallel above
y_partial = x_partial @ W_i ← each GPU computes partial output
y = all_reduce(y_partial) ← sum partial results → full output
ATTENTION HEADS:
Split n_heads across GPUs: GPU_i handles heads [i*h : (i+1)*h]
Q, K, V projections: column parallel
Output projection: row parallel
One all-reduce per attention layer (not per head)
Project 7 — State Space Model (Mamba)
SSM Fundamentals
CONTINUOUS-TIME STATE SPACE:
h'(t) = Ah(t) + Bx(t) ← state update (A: n×n, B: n×1)
y(t) = Ch(t) + Dx(t) ← output (C: 1×n, D: scalar)
DISCRETIZATION (for sequences):
Given step size Δ (learned):
Ā = exp(ΔA) ← Zero-Order Hold (ZOH)
B̄ = (ΔA)⁻¹(exp(ΔA) - I)ΔB
Discrete recurrence:
h_t = Āh_{t-1} + B̄x_t ← O(1) per step at inference!
y_t = Ch_t
CONVOLUTIONAL VIEW (for training):
Unroll recurrence:
y_t = Σₖ C Āᵏ B̄ x_{t-k} ← convolution with kernel K
K = [CB̄, CAB̄, CA²B̄, ...] ← causal convolution kernel
Training: compute K once → parallel FFT convolution over all t
Inference: use recurrent form → constant memory
Best of both: parallel training like Transformers, O(1) inference memory
Mamba (Selective State Space)
KEY INNOVATION: Make A, B, C, Δ INPUT-DEPENDENT
Standard SSM: time-invariant (same A,B,C for all positions)
Mamba: Δ, B, C = Linear(x) → each input selects its own dynamics
Why this matters:
Allows model to selectively "remember" or "forget" based on content
Equivalent to discrete-time attention with linear complexity
MAMBA BLOCK:
x: (B, T, D)
# Input projection and selective SSM
x_ssm = Linear(D → d_inner)(x) # expand dimension
z = Linear(D → d_inner)(x) # gating branch
x_ssm = depthwise_conv1d(x_ssm) # local context mixing
x_ssm = silu(x_ssm)
# Compute selection parameters (input-dependent)
Δ = softplus(Linear(d_inner → d_inner)(x_ssm) + Δ_bias)
B = Linear(d_inner → d_state)(x_ssm) # (B, T, d_state)
C = Linear(d_inner → d_state)(x_ssm) # (B, T, d_state)
# Discretize and apply SSM
# A is learnable but input-independent (log parameterization)
Ā = exp(Δ × A) # (B, T, d_inner, d_state)
B̄ = Δ × B # simplification
# Parallel scan (associative scan) over time dimension
y = selective_scan(x_ssm, Ā, B̄, C, D) # CUDA kernel
# Output gate
y = y * silu(z)
output = Linear(d_inner → D)(y)
MEMORY: O(d_state) hidden state at inference (vs O(T×d) for Transformer KV cache)
COMPUTE: O(T) per layer (vs O(T²) for attention)
Quality: ~Transformer level on language modeling
Weakness: struggles with tasks requiring exact long-range token recall
Project 8 — Interpretability Tool (SAE — Sparse Autoencoders)
The Superposition Problem
OBSERVATION: Neural networks represent MORE features than they have neurons
Features compete for neurons → polysemanticity
Single neuron activates for multiple unrelated concepts
SOLUTION: Sparse Autoencoder learns a WIDER, SPARSER basis
d_hidden << d_SAE (SAE hidden dim >> residual stream dim)
At any time, only k << d_SAE features active (sparse)
Each feature is more interpretable (monosemantic)
ANALOGY: Holographic compression. Information stored in superposition.
SAE "decompresses" into interpretable components.
SAE Architecture
class SparseAutoencoder(nn.Module):
def __init__(self, d_in: int, d_sae: int, k: int = 32):
"""
d_in: dimension of model activations (e.g., 4096 for residual stream)
d_sae: SAE hidden dimension (e.g., 16384 = 4× expansion)
k: sparsity: keep only top-k features active
"""
super().__init__()
self.k = k
self.W_enc = nn.Linear(d_in, d_sae, bias=True) # encoder
self.W_dec = nn.Linear(d_sae, d_in, bias=True) # decoder
# Initialize decoder columns to unit norm
nn.init.orthogonal_(self.W_dec.weight)
def encode(self, x: torch.Tensor) -> torch.Tensor:
"""Project activations into SAE space, apply sparsity."""
pre_acts = F.relu(self.W_enc(x)) # (B, d_sae)
# TopK sparsity: keep only k largest activations
topk_vals, topk_idx = torch.topk(pre_acts, self.k, dim=-1)
acts = torch.zeros_like(pre_acts)
acts.scatter_(-1, topk_idx, topk_vals)
return acts
def decode(self, acts: torch.Tensor) -> torch.Tensor:
return self.W_dec(acts)
def forward(self, x: torch.Tensor):
# Normalize decoder columns (unit norm constraint)
self.W_dec.weight.data = F.normalize(self.W_dec.weight.data, dim=0)
acts = self.encode(x)
x_hat = self.decode(acts)
# Loss: reconstruction + L1 sparsity penalty
recon_loss = F.mse_loss(x_hat, x)
l1_loss = acts.abs().mean() # encourage sparsity
loss = recon_loss + self.l1_coef * l1_loss
return x_hat, acts, loss
# Training:
# 1. Collect activations from model at a specific layer (e.g., residual stream)
# 2. Train SAE to reconstruct these activations with sparse features
# 3. Examine what each feature responds to → what concept does it represent?
Feature Interpretation Pipeline
def interpret_features(sae, activation_dataset, tokenizer, top_examples=10):
"""For each SAE feature, find inputs that maximally activate it."""
# Forward all data through SAE
all_acts = []
all_tokens = []
for batch_tokens, batch_acts in activation_dataset:
_, acts, _ = sae(batch_acts) # (B, T, d_sae)
all_acts.append(acts.detach())
all_tokens.append(batch_tokens)
all_acts = torch.cat(all_acts, dim=0) # (N, T, d_sae)
all_tokens = torch.cat(all_tokens, dim=0) # (N, T)
feature_interpretations = {}
for feature_idx in range(sae.d_sae):
# Find positions where this feature fires most strongly
feature_acts = all_acts[:, :, feature_idx] # (N, T)
# Top-k (doc, position) pairs
flat_acts = feature_acts.view(-1)
top_positions = flat_acts.topk(top_examples).indices
doc_ids = top_positions // all_acts.size(1)
pos_ids = top_positions % all_acts.size(1)
# Show context around activation
contexts = []
for doc, pos in zip(doc_ids, pos_ids):
context_start = max(0, pos-5)
context_end = min(all_tokens.size(1), pos+5)
token_context = all_tokens[doc, context_start:context_end]
text = tokenizer.decode(token_context)
activation_value = feature_acts[doc, pos].item()
contexts.append((text, activation_value, pos.item()))
feature_interpretations[feature_idx] = {
"top_contexts": contexts,
"mean_activation": feature_acts[feature_acts > 0].mean().item(),
"sparsity": (feature_acts > 0).float().mean().item()
}
return feature_interpretations
Project 9 — Data Curation Pipeline (MinHash / Deduplication)
Why Deduplication Matters
Problem: Web crawl data (CommonCrawl) contains massive duplication
- Same article scraped from 1000 mirror sites
- Boilerplate text (cookie notices, navigation menus)
- Near-duplicate versions of same document
Effects of training on duplicate data:
- Model memorizes exact text instead of generalizing
- Inflated benchmark scores (test set contamination)
- Wasted compute training on redundant information
Scale: CommonCrawl → after dedup, 30-50% of data often removed
MinHash LSH Pipeline
class MinHashDeduplicator:
def __init__(self, num_hashes=128, ngram_size=5, threshold=0.85):
self.num_hashes = num_hashes
self.ngram_size = ngram_size
self.threshold = threshold
# Random hash functions: h(x) = (ax + b) mod p
self.a = np.random.randint(1, 2**32, size=num_hashes)
self.b = np.random.randint(0, 2**32, size=num_hashes)
self.p = 2**31 - 1 # large prime
def compute_minhash(self, text: str) -> np.ndarray:
"""Compute MinHash signature for a document."""
# Generate n-grams (character-level)
tokens = text.lower().split()
ngrams = set()
for i in range(len(tokens) - self.ngram_size + 1):
ngrams.add(tuple(tokens[i:i+self.ngram_size]))
# For each hash function, find minimum hash value across all n-grams
signature = np.full(self.num_hashes, np.inf)
for ngram in ngrams:
ngram_hash = hash(ngram) & 0xFFFFFFFF # 32-bit hash
hashed = (self.a * ngram_hash + self.b) % self.p
signature = np.minimum(signature, hashed)
return signature.astype(np.uint32)
def jaccard_from_signatures(self, sig1: np.ndarray, sig2: np.ndarray) -> float:
"""Estimate Jaccard similarity from MinHash signatures."""
return float((sig1 == sig2).mean())
def build_lsh_buckets(self, signatures: np.ndarray, n_bands: int = 16):
"""LSH: group documents likely to be near-duplicates into same bucket."""
n_docs, n_hashes = signatures.shape
rows_per_band = n_hashes // n_bands
buckets = defaultdict(list)
for doc_id in range(n_docs):
for band in range(n_bands):
start = band * rows_per_band
end = start + rows_per_band
band_sig = tuple(signatures[doc_id, start:end])
bucket_key = hash((band, band_sig))
buckets[bucket_key].append(doc_id)
return buckets
def find_duplicates(self, documents: List[str]) -> List[Tuple[int, int]]:
"""Find all near-duplicate pairs."""
signatures = np.array([self.compute_minhash(d) for d in documents])
buckets = self.build_lsh_buckets(signatures)
duplicate_pairs = set()
for bucket_docs in buckets.values():
if len(bucket_docs) < 2:
continue
# Verify pairs in same bucket
for i, j in combinations(bucket_docs, 2):
sim = self.jaccard_from_signatures(signatures[i], signatures[j])
if sim >= self.threshold:
duplicate_pairs.add((min(i,j), max(i,j)))
return list(duplicate_pairs)
Project 10 — Multi-modal Projector (CLIP)
CLIP Architecture
CLIP = Contrastive Language-Image Pretraining (Radford et al, OpenAI 2021)
IMAGE TEXT
│ │
┌──────▼──────┐ ┌─────▼─────┐
│ Vision │ │ Text │
│ Encoder │ │ Encoder │
│ (ViT-L/14) │ │ (GPT-like)│
└──────┬──────┘ └─────┬─────┘
│ image_features │ text_features
│ (N, d_vision) │ (N, d_text)
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ Projection │ │ Projection │
│ W_i │ │ W_t │
│ d_vision→d │ │ d_text→d │
└──────┬──────┘ └──────┬──────┘
│ image_emb (N, d) │ text_emb (N, d)
└──────────────┬───────────────┘
│
L2 normalize both
│
N×N similarity matrix:
logit_scale × image_emb @ text_emb.T
│
Symmetric Cross-Entropy Loss:
diagonal = matching (i,i) pairs → label i
CLIP Training Loss
def clip_loss(image_features, text_features, logit_scale=np.log(1/0.07)):
"""Symmetric InfoNCE loss for CLIP."""
B = image_features.size(0)
# Normalize
image_features = F.normalize(image_features, dim=-1)
text_features = F.normalize(text_features, dim=-1)
# Similarity matrix (all image-text pairs in batch)
logit_scale = logit_scale.exp()
logits_per_image = logit_scale * image_features @ text_features.T # (B, B)
logits_per_text = logits_per_image.T
# Labels: diagonal is the matching pair
labels = torch.arange(B, device=image_features.device)
# Cross-entropy in both directions (symmetric)
loss_i = F.cross_entropy(logits_per_image, labels)
loss_t = F.cross_entropy(logits_per_text, labels)
return (loss_i + loss_t) / 2
# Scaling: N=32768 images per batch (4096 GPUs × 8 images)
# Large batch = many negatives = hard training signal
# logit_scale learnable, initialized to log(1/0.07) = 2.659
Multi-modal Projector (LLaVA-style)
GOAL: Connect vision encoder to language model
Vision tokens → language embedding space
ARCHITECTURE OPTIONS:
1. Linear projection (LLaVA v1):
vision_tokens (N_v, d_vision) @ W_proj → (N_v, d_llm)
Simple, fast, surprisingly effective
2. MLP projection (LLaVA v1.5):
vision_tokens → Linear → GELU → Linear → (N_v, d_llm)
Better alignment between modalities
3. Perceiver Resampler (Flamingo):
Q-Former: fixed K learned queries attend to vision tokens
Output: K tokens regardless of input image resolution
More compute, reduces vision token count
4. C-Abstractor:
Pooling + MLP to reduce vision tokens aggressively
TRAINING STAGES:
Stage 1: ALIGNMENT PRETRAINING
Freeze: LLM + vision encoder
Train: projection layer ONLY
Data: image-caption pairs (LAION, CC3M)
Goal: align vision features to LLM embedding space
Stage 2: INSTRUCTION FINE-TUNING
Freeze: vision encoder
Train: projection + LLM (full or LoRA)
Data: visual instruction following data (LLaVA-Instruct)
Goal: follow multimodal instructions
INFERENCE:
image → ViT patch embeddings → projection → visual_tokens
prompt → tokenizer → text_tokens
[image_tokens, text_tokens] → LLM → response
Image tokens treated like any other tokens (interleaved in sequence)
How These Projects Form One System
┌──────────────────────────────────────────────────────┐
│ MINI RESEARCH LAB INFRASTRUCTURE │
│ │
Pretraining ────▶ │ Distributed Training (FSDP + TP) │
│ │ │
Raw Web Data ───▶ │ Data Curation Pipeline (MinHash dedup) │
│ │ │
│ ┌────────▼──────────────────────────────────┐ │
│ │ POST-TRAINING LOOP │ │
│ │ │ │
│ │ SFT on curated data │ │
│ │ ↓ │ │
│ │ Synthetic Data Generation (Self-Instruct)│ │
│ │ ↓ │ │
│ │ RLHF (PPO) or DPO │ │
│ │ ↓ │ │
│ │ Distillation (smaller student models) │ │
│ └────────────────────────────────────────────┘ │
│ │
│ ARCHITECTURE VARIANTS: │
│ MoE routing → sparse model │
│ Mamba → linear-time sequence model │
│ CLIP projector → multimodal model │
│ │
│ ANALYSIS + SAFETY: │
│ SAE interpretability → understand what model learns│
│ Adversarial attacks → find failure modes │
│ NAS → search for better architectures │
│ │
│ SERVING: │
│ AI Gateway → route across model versions │
└──────────────────────────────────────────────────────┘
Full research cycle timeline:
Week 1-2: Data curation (dedup + quality filter)
Week 3-4: Pretraining / fine-tuning on curated data
Week 5: RLHF or DPO alignment
Week 6: Eval (eval harness, adversarial testing)
Week 7: Distillation to production models
Week 8: Interpretability analysis (SAE)
Ongoing: AI Gateway serves traffic, collects preference data → loop