Back to Blog All Courses
Week 01

Tensors & the Memory Model

The very first thing a FAANG screener probes: do you understand what a tensor actually is in memory? Views vs copies, strides, broadcasting, and device placement separate people who "used PyTorch" from people who understand it.

🎯
What interviewers probe here

Not "can you make a tensor" — but: does an operation share memory or copy it? Interviewers hand you a two-line snippet that mutates a view and ask what the original tensor now contains. Get strides and contiguity right and you signal systems-level understanding.

The Three Attributes That Define a Tensor

Every tensor is fully described by three things. If you can rattle these off and explain how each one bites you in practice, you've cleared the phone-screen bar.

📏
shape

The size along each dimension. t.shapetorch.Size([2, 3]). Drives broadcasting rules.

🔢
dtype

torch.tensor([1,2,3]) is int64, not float. A silent source of "why won't my model train" bugs.

🖥
device

cpu / cuda / mps. Mixing devices in one op is a hard runtime error — a favorite gotcha question.

The Classic: view vs reshape

This is the most-asked PyTorch tensor question. The answer hinges on contiguity.

Python / PyTorch
x = torch.arange(6).reshape(2, 3)
y = x.T                 # transpose: a VIEW

y.view(6)             # RuntimeError!
y.reshape(6)          # OK — silently copies
y.contiguous().view(6) # OK — explicit copy
What's happening
x is contiguous — row-major, no gaps.
.T swaps strides — same storage, different access pattern. Now non-contiguous.
 
view() refuses — it never copies, and a flat view of non-contiguous data would misread memory. So it errors.
reshape() adapts — returns a view if it can, else silently makes a copy.
.contiguous() materializes a fresh row-major copy so view() works.
💡
The senior-level follow-up

"When would you deliberately choose view over reshape?" — When you want a guaranteed no-copy contract. view failing loudly tells you a copy would have happened; reshape hides that cost. In a hot loop, an accidental copy per iteration is a real regression.

Rapid-Fire Conceptual Round

1. x = torch.arange(6).reshape(2,3); x.T.view(6) — result?

2. What dtype is torch.tensor([1, 2, 3])?

3. a = np.array([1,2,3]); t = torch.from_numpy(a); t.add_(1) — what is a?

Broadcasting — Explain It in One Sentence

Interviewers love: "walk me through the broadcasting rules." The one-liner they want: align shapes from the right; each dimension must be equal or one of them must be 1; size-1 dims are virtually expanded.

Broadcasting
a = torch.arange(3).reshape(3, 1)  # (3,1)
b = torch.arange(4).reshape(1, 4)  # (1,4)
c = a + b                          # (3,4)
Why (3,4)
Align right: (3,1) vs (1,4).
Dim -1: 1 vs 4 → 4 (the 1 expands).
Dim -2: 3 vs 1 → 3 (the 1 expands).
No data copied — expansion is virtual (stride 0).

Spot the Bug: Device Mismatch

You're asked to review a colleague's inference snippet on a GPU box. It crashes. Click the line that causes the runtime error.

model = Net().to(device)
x = torch.randn(1, 3, 224, 224)
out = model(x)  # x is still on CPU!
pred = out.argmax(dim=1)
The modern, device-agnostic fix

Don't hardcode "cuda". Current PyTorch (2.x) prefers torch.accelerator:

device = (torch.accelerator.current_accelerator()
          if torch.accelerator.is_available() else "cpu")
x = x.to(device)   # .to() returns a NEW tensor — must reassign

In-Place Ops: The Trap

Operations ending in _ (add_, mul_, relu_) mutate in place. They save memory — and can silently corrupt the autograd graph (Week 2). Interviewers use this to bridge into gradients.

Common follow-ups the interviewer asks

• "Does t.to(device) modify t?" — No, it returns a new tensor; you must reassign.
• "Does reshape always return a view?" — No, it copies when it must.
• "Why can in-place ops break backprop?" — They overwrite values the backward pass still needs; autograd detects this via version counters and raises.

Week 02

Autograd Deep Dive

The single most-tested PyTorch topic in ML-engineer loops. If you can explain the dynamic graph, gradient accumulation, and when to disable tracking — clearly, out loud — you pass the core round.

🎯
What interviewers probe here

They want the mental model: define-by-run. Every op on a tensor with requires_grad=True is recorded into a graph that is rebuilt each forward pass. .backward() walks it in reverse via the chain rule and accumulates into .grad. The word "accumulates" is where careers are made.

The Graph Is Built on Every Forward Pass

Unlike TensorFlow 1.x's static graph, PyTorch is define-by-run. This is why a plain Python if works inside forward() — the graph simply records whichever branch ran.

Python / PyTorch
w = torch.randn(5, 3, requires_grad=True)
b = torch.randn(3, requires_grad=True)
x = torch.ones(5)

z = x @ w + b           # graph recorded here
loss = z.sum()
loss.backward()         # reverse pass

print(w.grad)           # populated
What autograd does
Leaf tensors with requires_grad get a .grad slot.
 
 
 
Each op attaches a grad_fn recording how to differentiate it.
Must be scalar to call backward() with no args.
Chain rule in reverse fills .grad on every leaf.
 
Gradient accumulates into .grad (adds, not overwrites).

Watch the Graph Think

Here's how the forward tensors, the loss, and the backward engine coordinate. Step through it — this is exactly the narration you'd give on a whiteboard.

0 / 5 messages

Three Ways to Stop Tracking — Know the Differences

"What's the difference between no_grad, inference_mode, and detach?" is a staple. Here's the crisp answer:

1
torch.no_grad()

Context manager. Disables graph building for everything inside. Use for validation. Results can safely re-enter a training graph later.

2
torch.inference_mode()

Faster than no_grad — also skips version counters. But tensors created inside it can never enter an autograd graph. Use for pure, throwaway inference.

3
.detach()

Acts on one tensor: returns a graph-free view sharing storage. Use to snip a tensor out of the graph mid-computation.

Spot the Bug: The Silent Killer

This loop "trains" but the model barely learns. Click the missing/wrong line.

for x, y in loader:
  loss = loss_fn(model(x), y)
  loss.backward()  # grads accumulate...
  optimizer.step()
💡
The follow-up they love

"When would accumulation be a feature, not a bug?" — Gradient accumulation to simulate a larger batch: skip zero_grad/step for N micro-batches, then step once. Naming this unprompted signals real training experience.

Conceptual Round

1. You log total_loss += loss each batch and OOM after a while. Fix?

2. Calling loss.backward() twice on the same graph raises. Why / fix?

Common follow-ups the interviewer asks

• "Why does validation use no_grad?" — No graph → less memory, faster; outputs identical.
• "Which tensors get .grad?" — Only leaf tensors with requires_grad=True (call retain_grad() for non-leaves).
• "Backward on a non-scalar?" — Must pass a gradient= vector, or reduce to a scalar first.

Week 03

Models, Losses & the Training Loop

The classic live-coding prompt: "implement a training loop on a whiteboard." Get the five steps and their ordering right, and know why CrossEntropyLoss eats logits — that's the whole round.

🎯
What interviewers probe here

Can you write the loop from memory, in the right order, without a framework? And do you know the three landmines: logits vs probabilities, train() vs eval(), and where zero_grad goes?

Define a Model — the Registration Rules

Python / PyTorch
class Net(nn.Module):
  def __init__(self):
    super().__init__()   # MUST call first
    self.net = nn.Sequential(
      nn.Linear(784, 256), nn.ReLU(),
      nn.Linear(256, 10))  # raw logits

  def forward(self, x):
    return self.net(x)   # no softmax!
Why each line matters
 
 
super().__init__() — skip it and no parameters register. Instant bug.
Assigning nn modules as attributes auto-registers them into .parameters() and .to().
 
Output raw logits — CrossEntropyLoss applies log_softmax itself.
 
Call model(x), never model.forward(x) — the former runs hooks.

The Canonical Loop — Live-Coding Answer

Memorize this. The five steps, in order:

1

forward: pred = model(x)

2

loss

3

backward

4

step

5

zero_grad

Training loop
model.train()                     # dropout/BN in train mode
for x, y in loader:
  x, y = x.to(device), y.to(device)
  pred = model(x)                 # 1
  loss = loss_fn(pred, y)         # 2
  loss.backward()                 # 3
  optimizer.step()                # 4
  optimizer.zero_grad()           # 5
The ordering rule
train() enables Dropout & BatchNorm training behavior.
 
Move data to the model's device.
 
 
backward fills .grad.
step reads .grad, updates weights.
zero_grad clears for next iter. Never between backward and step!

Spot the Bug: The Loop That Won't Learn

A candidate wrote this in a screen. It compiles and runs — but the loss never drops. Click the offending line.

  loss = loss_fn(model(x), y)
  loss.backward()
  optimizer.zero_grad()  # wrong place!
  optimizer.step()

Losses & Optimizers — the Gotchas

CrossEntropyLoss Takes raw logits + integer class indices (long). It applies log_softmax internally. A Softmax layer before it = double softmax = broken training.
AdamW vs Adam AdamW decouples weight decay from the adaptive moments — the correct L2 regularization. Prefer it over Adam(weight_decay=...).

Conceptual Round

1. Final layer is nn.Softmax, loss is CrossEntropyLoss. What's wrong?

2. What does model.eval() change before validation?

Common follow-ups the interviewer asks

• "You stored layers in a Python list — why are they invisible to the optimizer?" — Use nn.ModuleList/nn.Sequential; a plain list isn't registered.
• "model.eval() vs no_grad() — same thing?" — No. eval() changes layer behavior; no_grad() disables the graph. You typically use both at validation.
• "Does eval() stop gradients?" — No, it does not.

Week 04

Data Pipeline & Reproducibility

Data-loading questions look easy and trip up seniors. num_workers, pin_memory, worker seeding, and "why is your augmentation duplicated across workers" are exactly the details that reveal production experience.

🎯
What interviewers probe here

The Dataset/DataLoader split (who does what), when workers help vs hurt, and reproducibility — the last one being a genuine differentiator because most candidates only seed the main process.

Dataset vs DataLoader — the Division of Labor

Dataset Defines how to access one sample: __getitem__ + __len__. All per-sample transforms live here.
DataLoader Wraps a Dataset: batching, shuffling, parallelism (num_workers), pinned memory, and collate_fn.

The Knobs That Come Up

Python / PyTorch
loader = DataLoader(
  ds, batch_size=64, shuffle=True,
  num_workers=4,
  pin_memory=True,
  persistent_workers=True,
  drop_last=True)

x = x.to(device, non_blocking=True)
What each does
 
 
num_workers — subprocesses that prefetch batches. Helps when __getitem__ is I/O or CPU bound.
pin_memory — page-locked host memory → faster, async H2D copies.
persistent_workers — keep workers alive across epochs (avoids re-spawn cost).
drop_last — drop the ragged final batch (BatchNorm stability).
non_blocking — overlaps the copy — only effective with pinned memory.
💡
The trap: "more workers = faster"

False for tiny, in-memory datasets — worker IPC/startup overhead dominates and num_workers=0 wins. Workers pay off only when per-sample work (disk read, decode, augmentation) is heavy. Saying this unprompted scores points.

Transforms: Know the Deprecation

A subtle "are you current?" signal: the modern API is torchvision.transforms.v2, and ToTensor is deprecated.

Modern (v2)
from torchvision.transforms import v2

tf = v2.Compose([
  v2.ToImage(),
  v2.RandomResizedCrop(224, antialias=True),
  v2.ToDtype(torch.float32, scale=True),
  v2.Normalize(mean=..., std=...)])
Why not ToTensor?
 
 
 
ToImage() converts to a tensor image (no scaling).
 
ToDtype(..., scale=True) replaces ToTensor's [0,255]→[0,1] scaling — v2.ToTensor is deprecated. v2 is also faster and can transform images + boxes + masks jointly.

Spot the Bug: Duplicated Augmentations

Training with num_workers=4 and random augmentation. Every 4th sample looks augmented identically. Click the root cause.

torch.manual_seed(0); np.random.seed(0)
loader = DataLoader(ds, num_workers=4)  # no worker_init_fn
def __getitem__(self, i): aug = np.random.rand()
The fix
def seed_worker(worker_id):
  s = torch.initial_seed() % 2**32
  np.random.seed(s); random.seed(s)
g = torch.Generator(); g.manual_seed(0)
DataLoader(ds, num_workers=4, worker_init_fn=seed_worker, generator=g)

Checkpointing — Conceptual Round

1. Save model.state_dict() or torch.save(model)? Why?

2. You resume training but loss spikes. You restored weights only. Missing?

Common follow-ups the interviewer asks

• "When do you need collate_fn?" — Variable-length data (pad sequences); default collate can't stack unequal tensors.
• "shuffle=True and a custom sampler together?" — Illegal, mutually exclusive.
• "Is determinism guaranteed across machines?" — No — only within the same PyTorch version/platform; deterministic algorithms are also slower.

Week 05

Building Blocks & Transfer Learning

BatchNorm vs LayerNorm is the most common "do you actually understand normalization" question. Transfer learning tests whether you know the modern weights= API — and the freeze-ordering bug that silently trains nothing.

🎯
What interviewers probe here

"When would you pick LayerNorm over BatchNorm?" and "how do you fine-tune a pretrained ResNet without wrecking its features?" — plus the classic ordering bug: freeze then replace the head, never the reverse.

BatchNorm vs LayerNorm — the One-Liner

📊
BatchNorm

Normalizes each feature across the batch. Uses batch stats in train, running stats at eval. Breaks with tiny/variable batches. Standard in CNNs.

📏
LayerNorm

Normalizes per-sample across features. Batch-size independent; identical in train & eval. Standard in Transformers & small-batch regimes.

The answer they want: "Use LayerNorm for sequence models/Transformers or when batches are small/variable, because it doesn't depend on batch statistics."

Match the Layer to Its Job

Drag each layer onto the use-case an interviewer would expect it for.

nn.LayerNorm
nn.Dropout
nn.Conv2d
nn.LSTM
Normalize per-sample in a Transformer block
Drop here
Regularize by randomly zeroing activations in training
Drop here
Extract spatial features from an (N,C,H,W) image
Drop here
Model a variable-length time series with hidden state
Drop here

Transfer Learning — the Modern API

Interviewers spot outdated candidates instantly: pretrained=True is deprecated. Use the Weights enum.

Feature extraction
from torchvision.models import resnet18, ResNet18_Weights

w = ResNet18_Weights.DEFAULT
model = resnet18(weights=w)
preprocess = w.transforms()   # exact preprocessing

for p in model.parameters():
  p.requires_grad = False   # freeze FIRST
model.fc = nn.Linear(model.fc.in_features, n)  # then replace
opt = torch.optim.SGD(model.fc.parameters(), lr=1e-3)
Why this order
 
 
DEFAULT = best available checkpoint.
 
w.transforms() gives the exact resize/crop/normalize — no guessing constants.
 
Freeze first — set requires_grad=False on everything.
Then replace fc — new modules default to requires_grad=True, so only the head trains.
Optimize only the head.

Spot the Bug: Nothing Trains

A candidate's fine-tuning code runs, loss is flat, accuracy never moves. Click the line responsible.

model = resnet18(weights=ResNet18_Weights.DEFAULT)
model.fc = nn.Linear(model.fc.in_features, 10)
for p in model.parameters(): p.requires_grad = False
opt = torch.optim.SGD(model.parameters(), lr=1e-3)

Conceptual Round

1. Why use weights.transforms() instead of writing Resize/Normalize yourself?

2. nn.LSTM(..., num_layers=1, dropout=0.5) — effect of dropout?

Common follow-ups the interviewer asks

• "Feature extraction vs fine-tuning — when each?" — Freeze-and-train-head for small data; fine-tune (small LR) with more data.
• "Why fine-tune at a lower LR?" — A high LR destroys the pretrained features before the head adapts.
• "BatchNorm at inference on a fine-tuned model?" — Call eval() so it uses running stats, or accuracy degrades.

Week 06

Mixed Precision & Performance

Where ML-engineer loops separate the "I trained a model" crowd from the "I made training 2× faster" crowd. AMP mechanics, the fp16-vs-bf16 scaler question, and the .item() sync anti-pattern come up constantly.

🎯
What interviewers probe here

Do you understand why a GradScaler exists, that backward runs outside autocast, and that you're using the current torch.amp API (not the deprecated torch.cuda.amp)? Plus: can you spot a hidden CPU↔GPU sync killing throughput?

Why GradScaler Exists

AMP runs eligible ops in fp16/bf16. But fp16 has a narrow dynamic range — small gradients underflow to zero. The GradScaler multiplies the loss by a large factor so gradients stay representable, then unscales before the optimizer step.

The Correct AMP Loop (Current API)

torch.amp (2.x)
from torch.amp import autocast, GradScaler
scaler = GradScaler("cuda")

for x, y in loader:
  optimizer.zero_grad()
  with autocast(device_type="cuda"):
    out = model(x)          # forward + loss ONLY
    loss = loss_fn(out, y)
  scaler.scale(loss).backward()   # OUTSIDE autocast
  scaler.step(optimizer)
  scaler.update()
The rules
torch.amp — NOT torch.cuda.amp (deprecated).
GradScaler("cuda") with explicit device.
 
 
 
autocast wraps ONLY forward + loss.
 
 
backward is outside — it auto-uses the chosen dtypes.
scaler.step unscales, then steps.
scaler.update adjusts the scale factor.

Spot the Bug: Broken AMP

This AMP loop produces NaNs and gradient clipping seems to do nothing. Click the line at fault.

  with autocast(device_type="cuda"): loss = loss_fn(model(x), y)
  scaler.scale(loss).backward()
  clip_grad_norm_(model.parameters(), 1.0)  # grads still scaled!
  scaler.step(optimizer); scaler.update()
Correct clipping with AMP
scaler.scale(loss).backward()
scaler.unscale_(optimizer)              # unscale FIRST
clip_grad_norm_(model.parameters(), 1.0)
scaler.step(optimizer); scaler.update()

The fp16-vs-bf16 Question

1. Do you need GradScaler with bf16 autocast?

2. Kernel times are tiny but wall-clock is high; code calls loss.item() every step. Cause?

Profiling — Show You Can Measure

"How would you find the bottleneck?" Reach for torch.profiler with a schedule that skips warmup.

torch.profiler
from torch.profiler import profile, schedule, ProfilerActivity

with profile(
  activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
  schedule=schedule(wait=1, warmup=1, active=3)) as p:
  for _ in range(5):
    model(inputs); p.step()
print(p.key_averages().table(sort_by="cuda_time_total"))
Why warmup
 
 
 
wait/warmup skip CUDA init, cuDNN autotune, allocator warmup, and torch.compile compilation — which would distort numbers.
 
active steps reflect true steady-state cost.
export_chrome_trace for a timeline to see kernel gaps.
Common follow-ups the interviewer asks

• "Should backward() be inside autocast?" — No, only forward + loss.
• "fp16 on a bf16-pretrained LLM?" — Risky; fp16 max ≈ 65504, can overflow. Prefer bf16.
• "CUDA is async — how do you time correctly?" — torch.cuda.synchronize() before/after, or use the profiler.
• "Does empty_cache() fix OOM?" — No; it returns cached memory to other processes, doesn't free your tensors or fix fragmentation.

Week 07

torch.compile & Distributed Training

Senior and staff loops live here. Explain the compile pipeline, guards and recompiles, and — the big one — when DDP vs FSDP. Get the "does the model fit on one GPU" heuristic right and you've answered the core distributed question.

🎯
What interviewers probe here

The torch.compile pipeline (Dynamo → AOTAutograd → Inductor), what a graph break and a recompile are, and the DDP-vs-FSDP decision framed by the single question: does the model fit on one device?

The Compile Pipeline in One Breath

1

Dynamo: bytecode → FX graph + guards

2

AOTAutograd: joint fwd/bwd graph

3

Inductor: Triton (GPU) / C++ (CPU)

Usage
model = torch.compile(model)              # one line
model = torch.compile(model, mode="max-autotune")
model = torch.compile(model, fullgraph=True)  # no graph breaks
Modes & flags
default — balanced.
max-autotune — Triton-templated matmul/conv autotuning + CUDA graphs. Best for serving.
fullgraph=True — errors on any graph break; use in dev to find them.

Spot the Bug: Slower After Compiling

A candidate wraps their model in torch.compile and it's slower than eager, recompiling constantly. Click the cause.

model = torch.compile(model)
for x in variable_length_batches:  # shapes change every step
  out = model(x)
🔍
Diagnose it

TORCH_LOGS="recompiles,graph_breaks" python train.py prints exactly why Dynamo recompiled or broke the graph. Mentioning this tool is a strong signal. Fix shape churn with dynamic=True.

The Decision: DDP vs FSDP

🔁
DDP

Model fits on one GPU. Full replica per rank, data split across ranks, one all-reduce per step. Lowest overhead. This is your default.

🧩
FSDP2

Model does NOT fit. Shards params + grads + optimizer state. all-reduce → reduce-scatter + all-gather. Use fully_shard (FSDP1 is deprecated).

FSDP2 — bottom-up
from torch.distributed.fsdp import fully_shard
for layer in model.layers:
  fully_shard(layer)      # shard submodules first
fully_shard(model)        # then the root
opt = torch.optim.Adam(model.parameters())  # AFTER sharding!
The ordering rule
 
 
Shard leaves first, root last.
 
Build the optimizer AFTER fully_shard — params become sharded DTensors; an optimizer built before points at the wrong objects. Classic senior gotcha.

Watch DDP Ranks Sync

Step through one training step across two GPUs — this is the narration you'd give when asked "how does DDP keep replicas in sync?"

0 / 4 messages

Conceptual Round

1. Model fits on one GPU; you want throughput across 8 GPUs. DDP or FSDP?

2. What triggers a torch.compile recompilation?

Common follow-ups the interviewer asks

• "What's a graph break?" — A point Dynamo can't trace (data-dependent control flow, .item(), printing) → splits the graph, limits fusion.
• "Why is the first iteration slow?" — Compilation cost; benchmark steady state.
• "How do you launch DDP?" — torchrun --nproc_per_node=8 train.py; it sets RANK/LOCAL_RANK/WORLD_SIZE.
• "Always destroy_process_group()" — else ranks hang.

Week 08

Expert Internals & ML System Design

The staff-level capstone. Custom ops and the compiler contract, export/deployment, memory mastery, and two full ML system-design prompts with the rubric interviewers actually score against.

🎯
What interviewers probe here

Depth and judgment: can you make a custom kernel compose with torch.compile, reason about the CUDA allocator and attention memory, and — most of all — drive a system-design discussion with tradeoffs, not buzzwords?

Custom Ops: The Compiler Contract

The modern way to insert a C++/CUDA/Triton kernel is torch.library.custom_op. The key insight interviewers want: torch.compile treats a custom op as an opaque black box, so you must hand it the metadata it can't infer — chiefly a fake kernel.

torch.library (2.4+)
@torch.library.custom_op("mylib::my_op",
    mutates_args=())
def my_op(x: Tensor) -> Tensor:
  return my_cuda_kernel(x)

@my_op.register_fake
def _(x):
  return torch.empty_like(x)  # metadata MUST match
The contract
mutates_args — required, keyword-only. Declare every arg the kernel writes to, or functionalization corrupts results.
 
The real kernel — opaque; compile won't trace into it.
 
register_fake — returns shape/dtype/device/strides, no data reads. Wrong strides → silently wrong compiled output.
💡
The sharp distinction

For a Triton kernel you want optimized, use torch.library.triton_op + wrap_tritontorch.compile traces into it and fuses around it. For a closed-source .so, use custom_op — opaque is exactly right. Knowing which to reach for is a staff-level signal.

Export, Deployment & Memory Levers

torch.export Whole-program AOT capture. One graph, no fallback. strict=False is the default since 2.7 — be explicit. Feeds AOTInductor for a self-contained .pt2.
CUDA allocator Caches freed blocks (reserved ≥ allocated). Fragmentation causes OOM with bytes free. PYTORCH_ALLOC_CONF=expandable_segments:True is the first lever.
SDPA / FlashAttention F.scaled_dot_product_attention fuses attention; auto-dispatches to FlashAttention. Turns O(N²) memory into O(N). Force a backend with sdpa_kernel.
Activation checkpointing Trade compute for memory — drop & recompute activations in backward. Use use_reentrant=False. Selective policy = the transformer sweet spot.

System Design #1: Train a 70B Model

"Design distributed training for a 70B-parameter LLM on a cluster of 8-GPU nodes." Here's the rubric a strong candidate hits:

1
Memory math first

70B params × (2 bytes weights + 2 grads + 8 optimizer states for Adam) ≈ 840 GB — before activations. It cannot fit on one GPU. State this to justify sharding.

2
Compose parallelism on a DeviceMesh

TP intra-node (fast NVLink) shards matmuls within a layer; FSDP2 inter-node shards params/grads/optimizer state across hosts. This 2D mesh is the standard large-model recipe.

3
Memory techniques

bf16 mixed precision, selective activation checkpointing, and loss_parallel to avoid gathering the vocab dimension. Add pipeline parallelism if still too large.

4
Placement rationale

TP is communication-heavy and blocking → keep it on NVLink inside a node. FSDP collectives tolerate slower inter-node links. State this tradeoff explicitly — it's the point of the question.

📈
The colwise/rowwise idiom (bonus)

In TP, shard FFN w1/w3 colwise and w2 rowwise so the whole block costs exactly one all-reduce at the end instead of communicating between every matmul. Dropping this detail marks you as having actually done it.

System Design #2: Serve an LLM at Low Latency

"Serve this model for real-time inference with tight p99 latency." The rubric:

1
Ahead-of-time compile

Use torch.export + AOTInductor to ship a prebuilt .pt2 — no JIT warmup, ~ms first-token vs seconds for torch.compile's first call. Deployment is the whole reason export exists.

2
Quantize for memory-bound decode

LLM decode is memory-bandwidth bound. int4/int8 weight-only quantization (torchao quantize_) shrinks the dominant weight traffic with minimal accuracy loss. Pair with torch.compile for fused kernels.

3
Attention & batching

FlashAttention via SDPA; continuous/in-flight batching to keep the GPU saturated; KV-cache management (the real memory bottleneck at long context).

4
CUDA graphs for launch overhead

Small decode kernels are CPU-launch bound. mode="reduce-overhead" (CUDA graphs) or explicit capture removes per-kernel launch cost — but requires static shapes and no .item() syncs.

Coding: A Custom autograd Function

"Implement a custom backward." The modern pattern splits pure compute (forward) from context setup (setup_context):

torch.autograd.Function
class MyReLU(Function):
  @staticmethod
  def forward(x):
    return x.clamp(min=0)
  @staticmethod
  def setup_context(ctx, inputs, output):
    ctx.save_for_backward(inputs[0])
  @staticmethod
  def backward(ctx, grad_out):
    x, = ctx.saved_tensors
    return grad_out * (x > 0)
What they check
 
 
forward is pure compute, runs outside autograd.
 
 
save_for_backward, not ctx.x = x — enables version tracking & prompt freeing.
 
backward returns one grad per forward input, order-matched.
Validate with gradcheck (use float64!).

1. Your register_fake returns the wrong strides. Eager works. What breaks?

2. Why run gradcheck with dtype=torch.double?

🏆
You've completed the bootcamp

Eight weeks: tensors and autograd, the training loop, data and reproducibility, blocks and transfer learning, mixed precision, torch.compile and distributed, and expert internals with system design. In an interview, always state the tradeoff, then the choice — that framing, more than any single API, is what earns the senior/staff signal.

Version-awareness (say these to sound current)

torch.export strict=False default since 2.7.
• FSDP2 fully_shard — FSDP1 is deprecated.
• Quantization lives in torchao (quantize_); torch.ao.quantization is legacy.
triton_op/wrap_triton for composable Triton kernels (2.6+).