Model Training Infrastructure and Distributed Training Questions
Scaling model training across hardware and time. Covers GPU/accelerator considerations, data and model parallelism, distributed and large-scale training, experiment tracking and training infrastructure, and the training-versus-inference compute tradeoff. Focuses on the systems and resource decisions that make large-model training feasible.
Implement gradient clipping functions in Python/numpy: one that clips by global L2-norm across all parameter gradients, and one that clips each gradient element by value. Explain when to use global-norm clipping versus per-value clipping, and discuss trade-offs such as introducing bias in gradient directions and potential effects on convergence.
Sample Answer
Direct answer
Global L2-norm clipping computes one scalar norm across all parameters' gradients combined and rescales every gradient by the same factor if that combined norm exceeds the threshold; per-element (value) clipping instead clamps each gradient entry independently to a fixed range, with no cross-parameter computation needed at all.
Structured elaboration
- Global norm clipping algorithm: compute the L2 norm of each parameter's gradient, combine them (square, sum across all parameters, square root) into one global norm, compare to
max_norm, and if exceeded, multiply every parameter's gradient by the same scale factormax_norm / (global_norm + epsilon)(the small epsilon avoids division by zero when the gradient is exactly zero). - Per-element clipping algorithm: for each gradient tensor, independently clamp every entry to the range
[-clip_value, clip_value], with no dependency on any other parameter's gradient.
Worked example
import numpy as np
def clip_grad_global_norm(gradients, max_norm, eps=1e-6):
# gradients: list of numpy arrays, one per parameter
global_norm = np.sqrt(sum(np.sum(g ** 2) for g in gradients))
if global_norm > max_norm:
scale = max_norm / (global_norm + eps)
gradients = [g * scale for g in gradients]
return gradients, global_norm
def clip_grad_by_value(gradients, clip_value):
return [np.clip(g, -clip_value, clip_value) for g in gradients]
# Verification
g1 = np.array([3.0, 4.0]) # norm 5
g2 = np.array([12.0]) # combined global norm: sqrt(5^2 + 12^2) = 13
grads = [g1, g2]
clipped, norm = clip_grad_global_norm(grads, max_norm=1.0)
assert np.isclose(norm, 13.0)
assert np.isclose(np.sqrt(sum(np.sum(g**2) for g in clipped)), 1.0) # rescaled to exactly max_norm
clipped_by_value = clip_grad_by_value(grads, clip_value=5.0)
assert np.allclose(clipped_by_value[0], [3.0, 4.0]) # both within [-5,5], unchanged
assert np.allclose(clipped_by_value[1], [5.0]) # 12.0 clamped down to 5.0
Direct execution confirms the global-norm-clipped gradients' combined norm equals exactly max_norm (1.0) as expected, and that value clipping leaves in-range components untouched ([3.0, 4.0], both under 5.0) while clamping the out-of-range component (12.0 clamped to 5.0) independently of the others, demonstrating the direction-preserving-versus-direction-distorting difference concretely: value clipping changed the relative proportion between g1 and g2 (originally 5:12, now 5:5 in effective magnitude after clipping only g2), while global norm clipping preserved the exact 5:12 ratio throughout.
Trade-offs & pitfalls
The classic implementation bug in global-norm clipping is computing each parameter's norm and clipping it independently (per-parameter norm clipping) rather than combining into one true GLOBAL norm across all parameters before deciding whether to clip and by how much; this is a subtly different (and less standard) algorithm that behaves quite differently, especially for models with very heterogeneous per-layer gradient magnitudes.
Explain gradient accumulation and why it is used. Provide a brief algorithmic description of how to implement gradient accumulation to emulate a larger batch size on limited-memory GPUs, and mention how it interacts with learning rate schedules and batch-norm statistics.
Sample Answer
Direct answer
Gradient accumulation simulates training with a larger batch size than fits in memory by running several forward/backward passes on smaller micro-batches, summing (or averaging) their gradients without updating the weights, and only applying the optimizer update once after the desired number of micro-batches has accumulated.
Structured elaboration
- Why it's needed: GPU memory limits the largest batch that fits for a given model; gradient accumulation decouples the "effective batch size" the optimizer sees from the "micro-batch size" that actually fits in memory, letting you train with a large effective batch on hardware that can't hold that batch's activations all at once.
- Algorithm: for each of K micro-batches, run forward and backward (accumulating
.gradon each parameter, since PyTorch's default is to add to existing gradients rather than overwrite), without callingoptimizer.step()orzero_grad()in between; after the Kth micro-batch, calloptimizer.step()once, thenzero_grad()to reset for the next cycle. - Loss scaling convention: divide each micro-batch's loss by K before calling
.backward(), so the summed gradient across K micro-batches equals the average gradient a true large-batch step would have produced, keeping the effective learning rate consistent with what was tuned for that effective batch size.
Worked example
To simulate an effective batch size of 128 on hardware that only fits 32 samples at a time: run 4 micro-batches of 32 samples each, dividing each micro-batch's loss by 4 before backward, accumulating gradients across all 4, then calling optimizer.step() once; the resulting parameter update is (approximately, modulo any batch-size-sensitive layers like BatchNorm) equivalent to what a single true batch-128 step would have produced.
Interaction with learning-rate schedules
A learning-rate scheduler's .step() must be called once per completed accumulation cycle (once per true optimizer step), not once per micro-batch; calling it every micro-batch effectively runs the schedule K times faster than intended relative to the actual number of samples/effective-batches seen, so a warmup or decay schedule tuned for, say, 1000 optimizer steps would finish in 1000 micro-batches instead of 1000*K micro-batches, a large, easy-to-miss bug. The scheduler's total step count (and any warmup-step count) should be computed as total_micro_batches / accumulation_steps, matching the optimizer_step counter, not the raw micro-batch or sample index.
Trade-offs & pitfalls
Gradient accumulation increases wall-clock time per effective step (K sequential forward/backward passes instead of one) since it doesn't add compute parallelism, only memory savings; it also doesn't fix batch-size-sensitive layers like BatchNorm, whose statistics are still computed per micro-batch rather than over the full effective batch, unless specifically handled (e.g. via sync-BN-style aggregation or accepting the approximation).
Explain best practices for creating containerized, reproducible training environments. What should be included in the container image (OS libs, CUDA/cuDNN versions, pip/conda packages), and how do you ensure experiments are reproducible across image versions and host kernels?
Sample Answer
Direct answer
A reproducible containerized training environment pins every layer of the software stack INSIDE the image (OS/system libraries, CUDA/cuDNN, framework and dependency versions, code) as a versioned, immutable image, and additionally accounts for the parts of the stack that live OUTSIDE the container on the host, principally the host's NVIDIA GPU driver and kernel version, which containerization alone does not pin.
Structured elaboration
- Base OS and system libraries: pin the base image to a specific tag, including specific versions of system-level libraries.
- CUDA/cuDNN versions: pin exact CUDA and cuDNN versions matching what the framework was validated against.
- Framework and dependency versions: pin exact versions for the framework and every dependency in a lockfile-style manifest (pip's
requirements.txtwith==pins, or a conda lockfile for conda-managed environments). - Code and configuration: bake the training code (or a specific commit hash reference) into the image, or record the commit hash alongside the image tag.
- Host kernel and GPU driver, the part containerization does NOT pin: for GPU workloads, the NVIDIA driver is installed on the HOST machine, not inside the container; the container only ships the CUDA TOOLKIT (compiler, runtime libraries), which must be compatible with, but is architecturally separate from, whatever driver version the host happens to have installed. Two hosts running the byte-identical container image can still produce different behavior (or fail to run at all) if their host driver versions differ enough to fall outside the CUDA toolkit's supported driver-compatibility range, or, more subtly, if a driver update changes low-level kernel scheduling or floating-point behavior in a way that affects numerical results at the margins. The host's Linux KERNEL version matters similarly for anything relying on kernel-level behavior the container doesn't isolate (e.g. specific NCCL/RDMA driver interactions, GPU peer-to-peer access configuration), which is invisible to and unmanaged by the container image itself.
- Mitigations for the host-side gap: document and enforce a MINIMUM supported host driver version (and ideally a validated, narrow range) alongside the image tag, since "any driver new enough to run this CUDA version" is a looser reproducibility bar than "this exact validated driver version"; where infrastructure allows it, standardize the host driver/kernel version across the fleet via the same infrastructure-as-code discipline applied to the container image itself (e.g. a pinned host AMI/base image for the node pool), rather than treating the host environment as out of scope simply because it's outside the container boundary.
- Reproducible builds: use a version-controlled Dockerfile that produces a deterministic image, and tag/publish images immutably.
Worked example
A training image tagged training:v2.3.1-cuda12.1-cudnn8.9-torch2.1.0 runs correctly and reproducibly on host A (driver 535.104) and host B (driver 550.90.07), both within CUDA 12.1's supported driver range, so the container-internal stack is identical, but a subtle numerical difference in one specific fused kernel is later traced to the driver version difference, not anything inside the image; the fix is documenting a validated driver version (or narrow range) as part of the reproducibility contract, not just the image tag, and re-running the validation suite whenever the fleet's host driver is upgraded.
Trade-offs & pitfalls
Pinning every version this strictly trades convenience for reproducibility guarantees. The host driver/kernel gap is a common blind spot precisely because it's easy to assume containerization solves reproducibility completely; teams that only pin the image and never document or standardize the host driver version can spend significant debugging time chasing an apparent "non-determinism" that's actually a host-environment difference the container never controlled.
You must train a model that consistently diverges (NaNs) on some data shards but not others in a distributed job. Provide a detailed debugging and mitigation plan to isolate the root cause.
Sample Answer
Direct answer
A model diverging (NaNs) on some data shards but not others in a distributed job points to a data-quality or numerical-edge-case issue specific to the content of the affected shards, rather than a general training instability, since a genuinely general numerical issue (unstable learning rate, precision problem) would typically manifest across all shards, not selectively.
Structured elaboration
- Isolate and inspect the specific affected shards: identify exactly which shard(s) trigger the NaN and directly inspect their data for known troublemakers: extreme outlier values, missing/malformed labels, division-by-zero-inducing inputs (e.g. a normalization step dividing by a feature that happens to be exactly zero in some examples), or corrupted/truncated records from a data-pipeline bug specific to how those particular shards were produced.
- Reproduce in isolation: once suspect shards are identified, run training (even just a forward pass) on ONLY that shard's data in a single-process, easy-to-debug environment, checking intermediate activations and losses for the first sign of a non-finite value, which usually pinpoints the exact problematic example(s) within the shard.
- Check shard-production provenance: since the issue is shard-specific rather than universal, investigate whether the affected shards were produced differently from the unaffected ones (a different preprocessing pipeline version, a different data source merged in, a partial/interrupted write during shard creation), which often explains why the bad data ended up isolated to specific shards rather than spread uniformly across the whole dataset.
- Mitigation once the root cause is found: options include filtering out the specific bad examples (if a small, identifiable number), adding input validation/sanitization at the data-loading layer (clamping or rejecting values outside an expected range before they reach the model), or adding numerical safeguards in the model/loss computation itself (e.g. adding a small epsilon to a denominator that could otherwise be exactly zero) if the underlying data is legitimately allowed to contain the edge case and it's the model's numerical handling, not the data, that needs to change.
Worked example
Tracing a NaN back to shard 47 out of 200, and inspecting that shard's raw examples directly, reveals a batch of examples with a categorical feature value that wasn't present in the vocabulary used to build the earlier shards (added later, after an upstream schema change), causing an out-of-vocabulary index lookup that returns an invalid (out-of-range or default-uninitialized) embedding value feeding into a downstream computation that eventually produces NaN; the root cause traces specifically to a schema-version mismatch in how shard 47 was produced relative to the others, not a general training-stability issue.
Trade-offs & pitfalls
The key diagnostic discipline distinguishing this from a general training-instability investigation is going straight to the specific data content of the affected shards rather than first exhausting general hyperparameter/numerical-stability hypotheses (learning rate, gradient clipping, mixed precision); since the symptom is explicitly shard-selective, the data itself is the highest-prior suspect and should be investigated first, not last.
Explain how batch size interacts with learning rate and gradient accumulation. Describe practical heuristics (e.g., linear learning rate scaling, warmup), how to compute effective batch size, and when gradient accumulation is preferable to increasing physical batch size.
Sample Answer
Direct answer
Batch size, learning rate, and gradient accumulation are tightly linked: increasing the effective batch size (whether by using a larger physical batch or by accumulating gradients over more micro-batches) generally calls for a proportionally larger learning rate to keep the effective step size in a well-behaved range, and a warmup period to avoid instability from applying that larger learning rate to an undertrained model right at the start.
Structured elaboration
- The linear scaling rule: as a practical heuristic, when multiplying the effective batch size by a factor k, multiply the learning rate by the same factor k (starting from a known-good learning rate at some baseline batch size); this heuristic comes from the observation that a k-times-larger batch produces a gradient estimate with roughly k-times-lower variance, so a proportionally larger step can be taken with similar stability to the smaller-batch, smaller-step baseline.
- Warmup: rather than applying the fully-scaled (and therefore larger) learning rate from step one, gradually ramp it up over an initial warmup period (a few hundred to a few thousand steps, depending on scale); this matters more, and for more steps, the larger the target learning rate is, since applying a large step size to a randomly-initialized, not-yet-stabilized model is a common source of early-training instability that warmup specifically mitigates.
- Gradient accumulation's role: since gradient accumulation is mathematically equivalent to a larger physical batch (once losses are correctly averaged across the accumulated micro-batches, as covered elsewhere in this topic), the same linear-scaling-and-warmup logic applies based on the EFFECTIVE batch size (micro-batch size times accumulation steps), not the smaller physical micro-batch size alone; a common mistake is tuning the learning rate as if the smaller micro-batch size were the real batch size, under-scaling the learning rate relative to what the true effective batch size calls for.
- Computing effective batch size precisely: in a distributed, gradient-accumulated setup, effective batch size = (micro-batch size per device) x (number of gradient accumulation steps) x (number of data-parallel devices); dropping any one of these three factors (e.g. forgetting to multiply by the device count in a multi-GPU job) is the specific version of the mistake above that shows up once accumulation and data parallelism are combined.
- When gradient accumulation is preferable to increasing physical batch size: gradient accumulation is the right tool specifically when the physical batch size you want is limited by GPU memory rather than by anything else; it lets you reach a larger EFFECTIVE batch size (for its statistical benefits, like a smoother, lower-variance gradient estimate) without needing more memory, at the cost of proportionally longer wall-clock time per optimizer step, since the accumulation steps run sequentially rather than in parallel. When memory headroom exists to simply increase the physical batch size instead, that is generally preferable, since it reaches the same effective batch size without the added wall-clock cost that accumulation's sequential micro-batches impose.
- Practical tuning approach: rather than trusting the linear scaling rule blindly at very large scale-up factors (where it's known to become less reliable), validate empirically with a learning-rate sweep at the target effective batch size, using the linear-scaling estimate as a reasonable starting point/search-range center rather than a guaranteed-correct final answer.
Worked example
A known-good configuration at batch size 256 with learning rate 1e-3: scaling to an effective batch size of 2048 (a factor of 8, achieved either via a larger physical batch or via accumulating gradients over 8 micro-batches of size 256) suggests a starting learning rate of 8×10−3 per the linear scaling rule, combined with a warmup period (e.g. the first 1000-2000 steps ramping linearly from a small initial value up to 8×10−3) rather than applying 8×10−3 immediately from step one.
Trade-offs & pitfalls
The linear scaling rule is a well-established heuristic, not a law; it tends to work well up to moderate batch-size increases but becomes less reliable at very large scale-ups (very large effective batch sizes), where more sophisticated LR schedules or additional techniques (e.g. layer-wise adaptive rate scaling) are sometimes needed, and empirical validation at the actual target scale remains the reliable final check rather than trusting the heuristic's extrapolation blindly.
Unlock Full Question Bank
Get access to all 22 Model Training Infrastructure and Distributed Training interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.