Everything for PyTorch in one place — pick a section below. 16 reviewed items across 4 content types.
Quick Answer
model.eval() switches layers like dropout and batch norm into inference behavior; torch.no_grad() disables autograd graph construction so no gradient buffers are allocated. They solve different problems — production inference should use both.
Detailed Answer
model.eval() is about layer semantics: dropout stops dropping activations and batch norm uses its running statistics instead of batch statistics. It does nothing to autograd — PyTorch will still record every operation for backprop, allocating memory for intermediate activations. torch.no_grad() (or the stricter torch.inference_mode()) is about memory and speed: it tells autograd not to build the computation graph at all, cutting inference memory roughly in half and speeding up the forward pass. Forgetting eval() gives you wrong (noisy or shifted) predictions; forgetting no_grad() gives you correct predictions that slowly eat your GPU memory under load. In serving code, wrap the model call in torch.inference_mode() and call model.eval() once at load time.
Code Example
model = MyModel()
model.load_state_dict(torch.load('weights.pt', map_location='cuda'))
model.eval() # once, at load time
@torch.inference_mode() # every request
def predict(batch):
return model(batch.to('cuda'))Interview Tip
Most candidates know one of the two. Saying "eval() is layer semantics, no_grad() is autograd memory — production needs both" in the first sentence signals you have actually served models, not just trained them.
💬 Comments
Quick Answer
Rule out a genuine leak (tensors kept alive by Python references or the autograd graph), then memory fragmentation, then unbounded batch/sequence sizes. torch.cuda.memory_summary() and memory snapshots tell you which it is.
Detailed Answer
OOM-after-hours is almost never the model itself — it loaded fine. The usual suspects, in order: (1) missing torch.no_grad(), so every request builds an autograd graph that is retained as long as the output tensor lives; (2) Python references keeping tensors alive — appending raw CUDA tensors to a metrics list or cache instead of calling .item() or .cpu(); (3) fragmentation — allocated memory is fine but there is no contiguous block big enough, common with highly variable input shapes; (4) unbounded inputs — one client sends a 10x-longer sequence and peak memory spikes. Diagnose with torch.cuda.memory_allocated() vs memory_reserved() over time (steady climb = leak; flat allocated but OOM = fragmentation), torch.cuda.memory_summary(), and the memory snapshot profiler. Fixes map one-to-one: inference_mode, .item()/.detach().cpu() on anything you keep, expandable_segments:True or bucketed padding for fragmentation, and hard caps on batch/sequence size at the API layer.
Code Example
# Watch for the leak signature print(torch.cuda.memory_allocated() / 1e9, 'GB allocated') print(torch.cuda.memory_reserved() / 1e9, 'GB reserved') print(torch.cuda.memory_summary()) # Fragmentation mitigation (PyTorch 2.x) # PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
Interview Tip
This is a mock-interview favorite. Structure the answer as leak vs fragmentation vs unbounded input, and name the exact signal that distinguishes them (allocated climbing vs reserved-but-unallocatable). That framework matters more than any single fix.
💬 Comments
Quick Answer
torch.compile traces your model with TorchDynamo and JIT-compiles fused kernels via TorchInductor, typically 1.3-2x speedups. Skip it when inputs have highly variable shapes (recompilation storms), when custom ops cause graph breaks, or when another engine like TensorRT-LLM already owns optimization.
Detailed Answer
torch.compile captures the Python-level model code into an FX graph (TorchDynamo), then compiles fused GPU kernels (TorchInductor), eliminating Python overhead and kernel launch latency. As of PyTorch 2.6+ it is production-stable and widely used for LLM inference, often combined with CUDA graphs. But it has real costs: a 30-60 second warm-up compile for a 7B model, cache invalidation on shape changes, and recompilation whenever guard conditions fail. Do not use it when (1) request shapes vary wildly and you cannot bucket-pad — recompilation overhead can exceed the speedup; (2) the model uses custom ops Dynamo cannot trace, so graph breaks fall back to eager and you keep the complexity without the gains; (3) you already serve through TensorRT-LLM or vLLM, which manage their own compilation; (4) cold-start latency matters more than steady-state throughput (e.g., scale-to-zero serverless). Also note a documented gotcha: torch.compile can create a CUDA context even for CPU-only code paths, which surprises multi-process CPU deployments.
Code Example
model = torch.compile(model, mode='reduce-overhead') # CUDA graphs for small batches
# Bucket-pad to avoid recompilation storms
# lengths -> nearest of {128, 256, 512, 1024}
x = F.pad(x, (0, bucket_len - x.shape[-1]))Interview Tip
Everyone can say "it makes models faster." Differentiate by naming the failure modes: recompilation storms from dynamic shapes, graph breaks from custom ops, and the compile-time cold-start tax.
💬 Comments
Quick Answer
DataParallel is single-process multi-GPU: one Python process scatters batches and gathers results each step, bottlenecked by the GIL and GPU-0. DistributedDataParallel runs one process per GPU with all-reduce gradient sync — faster, scales across nodes, and is the only option the team recommends.
Detailed Answer
DataParallel (DP) replicates the model onto each GPU inside a single process, splits each batch, and gathers outputs back on the primary GPU every forward pass. That design has three structural problems: the Python GIL serializes coordination, model replication happens every iteration, and GPU-0 becomes a memory/computation hotspot. DistributedDataParallel (DDP) launches one process per GPU (torchrun), each with its own model replica; gradients are synchronized with NCCL all-reduce, overlapped with the backward pass via gradient bucketing. DDP is faster even on a single machine, works across nodes, and composes with mixed precision and torch.compile. In practice DP survives only in quick notebook experiments; anything that matters uses DDP or, for models too big for one GPU, FSDP (fully sharded data parallel). Interview-wise, also know that DDP requires DistributedSampler so each rank sees a distinct data shard.
Code Example
# torchrun --nproc_per_node=4 train.py
dist.init_process_group('nccl')
rank = dist.get_rank()
model = DDP(model.to(rank), device_ids=[rank])
sampler = DistributedSampler(dataset)
loader = DataLoader(dataset, sampler=sampler, batch_size=64)Interview Tip
Name the three DP bottlenecks (GIL, per-step replication, GPU-0 hotspot) and mention FSDP as the answer for models that don't fit on one GPU — that shows you know the current landscape, not 2019's.
💬 Comments
Quick Answer
num_workers>0 forks worker processes that prefetch and transform batches in parallel so the GPU never starves; pin_memory=True stages batches in page-locked RAM enabling faster async host-to-GPU copies. Too many workers cause RAM blow-up, shared-memory errors, and fork overhead.
Detailed Answer
With num_workers=0 the main process loads data synchronously — the GPU idles during every batch load, and you will see low GPU utilization with high step time. Workers prefetch batches in parallel (each holds prefetch_factor batches in RAM), which usually fixes GPU starvation. Misconfiguration symptoms: too many workers multiply dataset memory (each worker gets a copy-on-write fork of the dataset object — deadly if the dataset holds a big in-RAM array), exhaust /dev/shm in containers (the classic 'DataLoader worker (pid X) is killed by signal: Bus error' in Kubernetes pods with the default 64MB shm), and add fork startup cost every epoch unless persistent_workers=True. pin_memory=True allocates the staging buffers in page-locked memory so .to('cuda', non_blocking=True) overlaps copy with compute; it costs host RAM and does nothing for CPU training. A sane starting point: num_workers = min(4-8, cores per GPU), pin_memory=True on GPU, persistent_workers=True, then profile — the right number is workload-specific.
Code Example
loader = DataLoader(ds, batch_size=128, num_workers=8,
pin_memory=True, persistent_workers=True,
prefetch_factor=4)
for x, y in loader:
x = x.to('cuda', non_blocking=True) # overlaps with compute
# K8s: shm too small for workers
# volumes: emptyDir: {medium: Memory} mounted at /dev/shmInterview Tip
Mentioning the Kubernetes /dev/shm bus error is a strong production signal — it's the single most common PyTorch-in-containers gotcha and most candidates have never heard of it.
💬 Comments
Quick Answer
AMP runs eligible ops in float16/bfloat16 while keeping numerically sensitive ops in float32, roughly halving memory and often near-doubling throughput on tensor cores. Risks: float16 gradient underflow (needs GradScaler), loss spikes on some models, and small numerical drift that can change model behavior.
Detailed Answer
torch.autocast wraps forward/loss computation and dispatches each op to a precision from an allow-list: matmuls and convolutions in half precision, reductions like softmax/layernorm in float32. With float16 you also need torch.cuda.amp.GradScaler, which scales the loss up before backward so tiny gradients don't underflow to zero, then unscales before the optimizer step and skips steps with inf/NaN. bfloat16 (Ampere+) has float32's exponent range, so it usually needs no scaler and is the default choice today. The risks: numerical drift means training curves and even inference outputs are not bit-identical to float32 — problematic for regression-tested pipelines; some architectures (large activations, certain attention patterns) hit NaN losses and need selective float32 casting; and validation done in a different precision than serving can hide accuracy gaps. Enable AMP with a controlled A/B on final metrics, not just speed.
Code Example
scaler = torch.cuda.amp.GradScaler()
for x, y in loader:
with torch.autocast('cuda', dtype=torch.bfloat16):
loss = criterion(model(x), y)
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
optimizer.zero_grad(set_to_none=True)Interview Tip
Say why bfloat16 mostly replaced float16 (same exponent range as fp32, so no GradScaler gymnastics) — it's a one-liner that dates your knowledge as current.
💬 Comments
Quick Answer
Export to ONNX Runtime or torch.export/AOTInductor for a Python-free, optimized runtime; quantize to int8; pin thread counts; and batch carefully. Raw eager PyTorch behind FastAPI is the fallback, not the goal.
Detailed Answer
For CPU inference the wins come from (1) getting out of eager Python execution, (2) quantization, and (3) thread discipline. Options: ONNX Runtime — export once, get graph fusions and a mature int8 quantization toolchain; usually the best latency/effort ratio and language-agnostic serving. torch.export + AOTInductor (the modern TorchScript replacement) — ahead-of-time compiles to a shared library loadable from C++ without the Python interpreter. TorchScript still works but is de-emphasized. Dynamic int8 quantization typically gives 2-4x on linear-heavy models with small accuracy cost — validate on a golden set. Thread control matters more than people expect: the default 'use all cores' setting causes thrashing when multiple workers share a box — set torch.set_num_threads / OMP_NUM_THREADS so workers x threads = physical cores. Finally decide batching policy: micro-batching helps throughput but hurts p99; for strict p99, batch size 1 with more replicas is often correct.
Code Example
# ONNX export + int8 torch.onnx.export(model, sample, 'model.onnx', dynamo=True) # quantize with onnxruntime.quantization.quantize_dynamic # Thread discipline per worker OMP_NUM_THREADS=4 uvicorn app:api --workers 8 # 32 physical cores
Interview Tip
Interviewers want the decision framework, not one tool: escape eager mode, quantize, control threads, then choose batching by latency SLO. Mentioning that torch.export/AOTInductor superseded TorchScript is a current-knowledge marker.
💬 Comments
Context
A team serving a 7B-parameter chat model on A100s with raw eager-mode PyTorch behind a FastAPI wrapper. GPU utilization was low and p50 latency dominated by Python overhead and kernel launches.
Problem
Token generation ran one small kernel launch at a time from Python; at batch sizes 1-4 the GPU spent more time waiting on the CPU to launch work than computing. Scaling out replicas was hiding a software problem with hardware spend.
Solution
Compiled the model with torch.compile(mode='reduce-overhead'), which wraps decode steps in CUDA graphs, and bucket-padded sequence lengths to {256, 512, 1024, 2048} to avoid recompilation storms. Warm-up requests at deploy time absorbed the 45-second compile cost before the pod entered the load balancer. Kept a compile-disabled env flag as an instant rollback.
Commands
model = torch.compile(model, mode='reduce-overhead')
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
kubectl rollout status deploy/llm-inference --timeout=10m
Outcome
p50 latency dropped 40%, tokens/sec/GPU rose 1.7x, and the fleet shrank from 12 to 7 GPUs for the same traffic. Recompilations flatlined after bucketing was added.
Lessons Learned
torch.compile pays off only after you control input shapes — bucketing came second but should have come first. Readiness probes must gate on warm-up completion or users eat the compile time.
💬 Comments
Context
A recommendation-model team whose single-GPU training run had grown to 30 hours per experiment, throttling iteration speed for a business-critical model.
Problem
Experiments were queued days deep. A naive DataParallel attempt gave only 1.6x on 4 GPUs due to GPU-0 bottleneck and GIL contention, and could not span multiple nodes at all.
Solution
Moved to DistributedDataParallel via torchrun: one process per GPU, NCCL backend, DistributedSampler for sharding, gradient accumulation to keep the effective batch size stable, and LR scaled linearly with world size with a warm-up. Checkpointing writes only from rank 0; a shared filesystem plus barrier keeps ranks consistent on resume.
Commands
torchrun --nnodes=4 --nproc_per_node=8 --rdzv_backend=c10d train.py
dist.init_process_group('nccl', timeout=timedelta(minutes=10))NCCL_DEBUG=INFO NCCL_IB_DISABLE=0 python -m torch.distributed.run ...
Outcome
30-hour runs became 70 minutes on 32 GPUs (25.7x scaling efficiency ~80%). Experiment throughput went from 5/week to 40/week, and offline metric gains shipped a quarter earlier than planned.
Lessons Learned
The hard parts were not model code: dataloader sharding correctness (validate each rank sees disjoint data), NCCL timeouts from one slow node, and remembering that random augmentation seeds must differ per rank while shuffle seeds must agree per epoch.
💬 Comments
Context
An e-commerce company running product-image classification (a fine-tuned ResNet variant) on every catalog upload — bursty traffic, thousands of images per minute at peak, no strict single-digit-ms requirement.
Problem
GPU serving was over-provisioned for a model this small, and the platform team wanted the workload on the existing autoscaled CPU Kubernetes fleet. Eager PyTorch on CPU missed the 150ms p99 target by 3x.
Solution
Exported the model to ONNX, applied dynamic int8 quantization with onnxruntime.quantization (accuracy drop 0.3% on the golden set, within budget), and served via ONNX Runtime with intra-op threads pinned to 4 and HPA scaling on queue depth rather than CPU. A nightly regression job compares ONNX outputs against the PyTorch reference on a fixed sample to catch export drift when the model retrains.
Commands
torch.onnx.export(model, dummy, 'resnet.onnx', dynamo=True)
python -m onnxruntime.quantization.preprocess --input resnet.onnx --output prep.onnx
OMP_NUM_THREADS=4 ORT_DISABLE_SPINNING=1 gunicorn app:server -w 8
Outcome
p99 fell from 460ms to 110ms, infra cost dropped ~55% versus the GPU deployment, and the service now rides the shared CPU autoscaling pool with no special node group.
Lessons Learned
Quantization accuracy must be re-validated on every retrain, not once — a later retrain shifted activation ranges and cost 1.1% accuracy until recalibrated. Thread pinning mattered as much as quantization for p99.
💬 Comments
Symptom
A PyTorch model server OOMs after several hours under load. torch.cuda.memory_allocated() climbs steadily even though traffic and batch sizes are constant. Restarting the pod resets it; the leak resumes immediately.
Error Message
torch.cuda.OutOfMemoryError: CUDA out of memory. Tried to allocate 384.00 MiB (GPU 0; 39.39 GiB total capacity; 37.80 GiB already allocated)
Root Cause
The handler ran the forward pass without torch.no_grad()/inference_mode(), so every request built an autograd graph. Raw output tensors were appended to an in-process metrics buffer, keeping each request's entire graph (activations included) referenced and un-freeable. Memory grew one request at a time until allocation failed.
Diagnosis Steps
Solution
Wrap the forward pass in torch.inference_mode(), and store only detached scalars in any long-lived structure: loss.item(), preds.detach().cpu().numpy(). After the fix, allocated memory plateaus within minutes of warm-up.
Commands
python -c "import torch; torch.cuda.memory._record_memory_history(max_entries=100000)"
watch -n5 nvidia-smi --query-gpu=memory.used --format=csv
py-spy dump --pid <server_pid>
Prevention
Code-review rule: any tensor stored beyond request scope must be .item() or .detach().cpu(). Add a canary alert on torch.cuda.memory_allocated() slope (bytes/minute over 30 min > threshold). Load-test for hours, not minutes — this class of leak is invisible in smoke tests.
💬 Comments
Symptom
Training jobs on Kubernetes crash intermittently at random steps with DataLoader worker killed messages. The same code with the same data runs fine on bare-metal dev boxes. Lowering num_workers to 0 makes crashes disappear but training becomes input-bound and slow.
Error Message
RuntimeError: DataLoader worker (pid 143) is killed by signal: Bus error. It is possible that dataloader's workers are out of shared memory. Please try to raise your shared memory limit.
Root Cause
PyTorch DataLoader workers pass batches to the main process through shared memory under /dev/shm. Docker/Kubernetes default /dev/shm to 64MB, which a few prefetched image batches exceed instantly. When a worker cannot allocate shm it is killed with SIGBUS — the error is environmental, which is why bare-metal (where /dev/shm is half of RAM) never reproduces it.
Diagnosis Steps
Solution
Mount a memory-backed emptyDir at /dev/shm in the pod spec (or docker run --shm-size=8g), sized to num_workers x prefetch_factor x batch bytes with headroom. Crashes stop immediately; keep num_workers at the tuned value.
Commands
kubectl exec -it <pod> -- df -h /dev/shm
kubectl patch deploy train --type=json -p '[{"op":"add","path":"/spec/template/spec/volumes/-","value":{"name":"shm","emptyDir":{"medium":"Memory","sizeLimit":"8Gi"}}}]'docker run --shm-size=8g ...
Prevention
Bake the shm mount into the standard training pod template / Helm chart so every new training job inherits it. Add a preflight check in the training entrypoint that warns when /dev/shm is smaller than a computed minimum for the configured DataLoader.
💬 Comments
Symptom
A 4-node DDP run freezes mid-epoch: GPU utilization drops to 0% on all nodes, no rank makes progress, and ~10 minutes later every rank crashes with a NCCL collective timeout. Logs on one node show a CUDA OOM shortly before the freeze that the job runner did not surface.
Error Message
torch.distributed.DistBackendError: NCCL communicator was aborted. Watchdog caught collective operation timeout: WorkNCCL(SeqNum=48231, OpType=ALLREDUCE, Timeout(ms)=600000) ran for 600012 milliseconds before timing out.
Root Cause
One rank hit CUDA OOM (its node also hosted a monitoring sidecar eating GPU memory) and exited its training loop, while the surviving ranks entered the next all-reduce and blocked waiting for it. NCCL collectives are cooperative — a single dead or straggling rank stalls every other rank until the watchdog timeout kills the job with a misleading 'timeout' error that hides the real OOM.
Diagnosis Steps
Solution
Fix the underlying OOM (evict the sidecar from GPU nodes, add per-rank memory headroom checks). Adopt torchrun with elastic restart so rank failure restarts the group from the last checkpoint instead of hanging, and set TORCH_NCCL_ASYNC_ERROR_HANDLING=1 so collectives fail fast rather than deadlock.
Commands
py-spy dump --pid <rank_pid>
NCCL_DEBUG=INFO torchrun --nnodes=4 --nproc_per_node=8 train.py
nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv
Prevention
Alert on per-rank heartbeats (steps/min per rank), not just job liveness — a hung job looks 'running' to the scheduler for the whole timeout. Log and aggregate stderr from every rank; the first failing rank's traceback is the diagnosis. Checkpoint frequently enough that elastic restarts are cheap.
💬 Comments