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.
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.
The size along each dimension. t.shape → torch.Size([2, 3]). Drives broadcasting rules.
torch.tensor([1,2,3]) is int64, not float. A silent source of "why won't my model train" bugs.
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.
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
"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.
a = torch.arange(3).reshape(3, 1) # (3,1)
b = torch.arange(4).reshape(1, 4) # (1,4)
c = a + b # (3,4)
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)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.
• "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.
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.
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.
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
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.
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:
Context manager. Disables graph building for everything inside. Use for validation. Results can safely re-enter a training graph later.
Faster than no_grad — also skips version counters. But tensors created inside it can never enter an autograd graph. Use for pure, throwaway inference.
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()"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?
• "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.
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.
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
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!
The Canonical Loop — Live-Coding Answer
Memorize this. The five steps, in order:
forward: pred = model(x)
loss
backward
step
zero_grad
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
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
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?
• "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.
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.
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
__getitem__ + __len__. All per-sample transforms live here.
num_workers), pinned memory, and collate_fn.
The Knobs That Come Up
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)
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.
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=...)])
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_fndef __getitem__(self, i): aug = np.random.rand()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?
• "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.
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.
"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
Normalizes each feature across the batch. Uses batch stats in train, running stats at eval. Breaks with tiny/variable batches. Standard in CNNs.
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.LayerNormnn.Dropoutnn.Conv2dnn.LSTMTransfer Learning — the Modern API
Interviewers spot outdated candidates instantly: pretrained=True is deprecated. Use the Weights enum.
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)
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 = Falseopt = 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?
• "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.
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.
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)
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()
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()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.
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"))
• "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.
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.
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
Dynamo: bytecode → FX graph + guards
AOTAutograd: joint fwd/bwd graph
Inductor: Triton (GPU) / C++ (CPU)
model = torch.compile(model) # one line
model = torch.compile(model, mode="max-autotune")
model = torch.compile(model, fullgraph=True) # no graph breaks
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)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
Model fits on one GPU. Full replica per rank, data split across ranks, one all-reduce per step. Lowest overhead. This is your default.
Model does NOT fit. Shards params + grads + optimizer state. all-reduce → reduce-scatter + all-gather. Use fully_shard (FSDP1 is deprecated).
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!
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?"
Conceptual Round
1. Model fits on one GPU; you want throughput across 8 GPUs. DDP or FSDP?
2. What triggers a torch.compile recompilation?
• "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.
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.
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.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
For a Triton kernel you want optimized, use torch.library.triton_op + wrap_triton — torch.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
.pt2.
PYTORCH_ALLOC_CONF=expandable_segments:True is the first lever.
F.scaled_dot_product_attention fuses attention; auto-dispatches to FlashAttention. Turns O(N²) memory into O(N). Force a backend with sdpa_kernel.
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:
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.
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.
bf16 mixed precision, selective activation checkpointing, and loss_parallel to avoid gathering the vocab dimension. Add pipeline parallelism if still too large.
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.
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:
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.
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.
FlashAttention via SDPA; continuous/in-flight batching to keep the GPU saturated; KV-cache management (the real memory bottleneck at long context).
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):
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)
ctx.x = x — enables version tracking & prompt freeing.gradcheck (use float64!).1. Your register_fake returns the wrong strides. Eager works. What breaks?
2. Why run gradcheck with dtype=torch.double?
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.
• 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+).