LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 177 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/8
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/11
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageClustering and PCACore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/24
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernanceData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/16
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/19
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning & Test-Time Compute
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnComputing FoundationsCUDA for ML Training
⚡EasyFine-Tuning & Training

CUDA for ML Training

Build beginner-first CUDA intuition for model training: CPU vs GPU roles, host-device copies, asynchronous execution, PyTorch device placement, and first-line debugging of OOM and performance issues.

14 min read
Learning path
Step 5 of 177 in the full curriculum
NumPy and Tensor ShapesMPS & Metal for ML on Mac

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

Platform path

If you train on a Mac with Apple silicon, pair it with MPS & Metal for ML on Mac. Same device-placement ideas, different backend and setup checks.

CUDA isn't a separate "AI mode." Take a production-shaped access-ticket batch with shape (32, 128, 768): 32 tickets, 128 tokens per ticket, and 768 features per token. The previous NumPy shapes lesson used a tiny (2, 3, 4) teaching tensor; the same (B, T, D) contract still applies here at a larger scale. CUDA adds a second contract to that shape contract: where the batch, weights, activations, and gradients live while training runs.

CUDA is NVIDIA's parallel computing platform and programming model. A CPU is built to run a small number of complicated threads with low latency; a GPU is built to run huge amounts of similar arithmetic at high throughput. For dense tensor work like matrix multiplies and attention, that difference can be dramatic once work becomes a large GPU kernel.[1]Reference 1CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/ The flip side matters too: tiny tensors and repeated copies can spend more time on launch and transfer overhead than on useful math.

The arrays are the same ones you already learned to reason about. Now device placement becomes part of the meaning of every tensor. You'll check an environment, move a training batch, catch a placement failure before a forward pass, budget memory, and measure asynchronous work honestly.[2]Reference 2CUDA semantics.https://docs.pytorch.org/docs/2.12/notes/cuda.html

Comparison of ping-pong host-device copies versus one batch copy to CUDA: shape (32, 128, 768) stays fixed, training stays on device, and only a loss scalar returns to the CPU.
Track the one expensive boundary crossing: copy the ticket batch to CUDA once, keep the forward and backward pass there, then bring back only the scalar loss for logging.

CPU orchestration vs GPU execution

A training loop has two different jobs:

  1. The CPU side handles Python control flow, dataloading, launching kernels, logging, checkpointing, and filesystem work.
  2. The GPU side handles the heavy tensor math: matrix multiplies, attention kernels, layer norms, optimizer updates, and other parallel operations.

That split matters because the CPU and GPU don't share one flat memory space in the way beginners often imagine. On the standard discrete-GPU path, the GPU has its own device memory. If your tensors live on the CPU, GPU kernels can't use them until you copy them over.[1]Reference 1CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/

Keep this practical comparison in your head:

WorkloadCPU usually wins whenGPU usually wins when
Python control flow, branching, filesystem workthe work is serial, branchy, or tinynot the right tool
Tensor maththe tensor is so small that transfer and launch overhead dominatethe operation is large, batched, and parallel, like matrix multiplication, convolutions, or attention
End-to-end training stepdataloading, logging, or synchronization stalls the loopweights, activations, and batches already live on device and kernels stay large enough to saturate throughput

Think about a CPU coordinator and a GPU worker pool. The CPU schedules work, but the GPU performs the bulk tensor math. Making the CPU path faster doesn't remove the GPU bottleneck when tensor operations dominate the work.

What CUDA is

CUDA is NVIDIA's GPU computing platform and programming model.[1]Reference 1CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/ In practice, for most AI engineers, that means five related ideas:

  • Kernels: GPU functions the host launches. From PyTorch's point of view, x @ w or loss.backward() queues one or more kernels; you rarely write the kernel body yourself.
  • Thread hierarchy: each kernel launch runs a grid of blocks; each block is a scheduling unit of threads that can share fast on-chip memory. The runtime maps blocks onto the GPU's streaming multiprocessors.
  • Warps: on NVIDIA GPUs, threads execute in lockstep groups of 32. If threads in the same warp take different branches, inactive lanes idle until the paths reconverge, which is why branch-heavy kernels lose throughput.[1]Reference 1CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/
  • Device memory: in the standard discrete-GPU setup, the GPU has a device-memory pool separate from host RAM.
  • Asynchronous launch: the CPU often queues GPU work and continues running until something forces synchronization.

You don't need to write custom CUDA kernels on day one. You do need to understand that model layers, loss computation, backward passes, and optimizer updates launch GPU work once their tensors are on a CUDA device.

Select a compatible PyTorch build

The NVIDIA System Management Interface command, nvidia-smi, reports devices visible to the NVIDIA driver. PyTorch's torch.cuda.is_available() reports whether this Python process can use CUDA. One can succeed while the other fails: for example, the driver may see a GPU while your environment has a CPU-only PyTorch installation.[3]Reference 3nvidia-smi documentationhttps://docs.nvidia.com/deploy/nvidia-smi/index.html[2]Reference 2CUDA semantics.https://docs.pytorch.org/docs/2.12/notes/cuda.html

Run these checks before changing code:

terminal
1nvidia-smi 2python3 - <<'PY' 3import torch 4print("torch version:", torch.__version__) 5print("compiled CUDA runtime:", torch.version.cuda) 6print("CUDA available:", torch.cuda.is_available()) 7PY

If the process can't access CUDA, use PyTorch's official installation selector for the current operating system, package manager, and supported CUDA option.[4]Reference 4Get Started.https://pytorch.org/get-started/locally/ Wheel tags and supported runtimes change; a hard-coded installation command in an article ages badly.

First device checks in PyTorch

Before you worry about throughput, make sure tensors land where you think they do. This script is intentionally device-agnostic: it runs on a CUDA machine and remains executable on a CPU-only laptop.

cuda_sanity_check.py
1import torch 2 3device = torch.device("cuda" if torch.cuda.is_available() else "cpu") 4print(f"selected device: {device}") 5print(f"cuda available: {torch.cuda.is_available()}") 6 7x = torch.arange(6, dtype=torch.float32).reshape(2, 3).to(device) 8y = (x * 2).sum(dim=1) 9 10print(f"x device: {x.device}") 11print(f"y device: {y.device}") 12print(f"result: {y.detach().cpu().tolist()}")
Output
1selected device: cuda 2cuda available: True 3x device: cuda:0 4y device: cuda:0 5result: [6.0, 24.0]

The output above is the happy path from a configured NVIDIA machine. If your local run reports no accessible CUDA device, that doesn't automatically mean your code is wrong. It means one of these is true:

  • you're on a machine without an NVIDIA GPU
  • the driver is missing or mismatched
  • the environment isn't linked to a CUDA-enabled PyTorch build
  • the process can't access the GPU

The first quick checks are usually:

terminal-2
1nvidia-smi 2python3 -c "import torch; print(torch.cuda.is_available())" 3python3 -c "import torch; print(torch.__version__, torch.version.cuda)"

nvidia-smi tells you whether the driver sees the device. PyTorch tells you whether the framework can use it.

Host memory vs device memory

Next comes memory placement.

Where data livesTypical examplesWhy it matters
Host RAMPython objects, dataset rows, CPU tensorsEasy to manipulate from Python; ordinary training tensors need a transfer before CUDA kernels use them
Device memorymodel weights, activations, gradients, optimizer buffers on GPUFast for GPU compute, bounded per device, and expensive to overflow

An out-of-memory (OOM) failure is local to the device running your job. A model that loads can still fail on its first training batch because weights are only one part of the budget. For a simplified full-precision Adam optimizer floor, count weights, gradients, and Adam's two running statistics. This still excludes activations, temporary buffers, and allocator overhead, so it's a lower bound rather than a capacity promise.

training_memory_floor.py
1params = 1_000_000_000 2bytes_per_param = { 3 "fp32 weights": 4, 4 "fp32 gradients": 4, 5 "fp32 Adam moments": 8, 6} 7 8total_bytes = sum(params * bytes_each for bytes_each in bytes_per_param.values()) 9gib = total_bytes / (1024 ** 3) 10print(f"parameter-related floor: {gib:.2f} GiB") 11print("activations and temporary buffers: add more memory")
Output
1parameter-related floor: 14.90 GiB 2activations and temporary buffers: add more memory

Three beginner rules cover most cases:

  1. Model and inputs must be on compatible devices.
  2. Every host-device copy costs time.
  3. Training failures often come from memory, not math alone.

A standard PyTorch training loop usually does both:

ticket_batch_placement.py
1import torch 2import torch.nn as nn 3 4device = torch.device("cuda" if torch.cuda.is_available() else "cpu") 5model = nn.Linear(768, 3).to(device) 6ticket_batch = torch.randn(32, 128, 768).to(device) 7logits = model(ticket_batch) 8 9print("model device:", next(model.parameters()).device) 10print("batch device:", ticket_batch.device) 11print("logits shape:", tuple(logits.shape))
Output
1model device: cuda:0 2batch device: cuda:0 3logits shape: (32, 128, 3)

GPU index can vary; model and batch still need matching CUDA devices, and the shape contract stays stable. CUDA_VISIBLE_DEVICES (covered in the Git chapter) can hide cards from the process: after a mask of 1, PyTorch's cuda:0 is the host's physical GPU 1. Always read placement from next(model.parameters()).device rather than assuming a fixed physical index.

Real batches often contain inputs, labels, and masks. Move every tensor that participates in device work:

move_whole_batch.py
1import torch 2 3device = torch.device("cuda" if torch.cuda.is_available() else "cpu") 4batch = { 5 "token_features": torch.randn(4, 8, 16), 6 "attention_mask": torch.ones(4, 8, dtype=torch.bool), 7 "labels": torch.tensor([0, 2, 1, 0]), 8} 9moved = {name: tensor.to(device) for name, tensor in batch.items()} 10 11assert all(tensor.device.type == device.type for tensor in moved.values()) 12print("all batch fields moved:", sorted(moved))

If one input stays on CPU while model parameters are on CUDA, the forward pass fails. A small preflight check makes that failure readable before a long training run begins:

catch_device_mismatch.py
1import torch 2 3def require_same_device(model_device: torch.device, batch: torch.Tensor) -> None: 4 # Bare torch.device("cuda") has index None; tensors placed with .to("cuda") 5 # report cuda:0. Direct != would false-fail matching placements. Compare 6 # type plus resolved index (None means the current CUDA device). 7 def resolved(device: torch.device) -> tuple[str, int | None]: 8 if device.type == "cuda": 9 idx = device.index 10 if idx is None: 11 idx = torch.cuda.current_device() if torch.cuda.is_available() else 0 12 return ("cuda", idx) 13 return (device.type, device.index) 14 15 if resolved(batch.device) != resolved(model_device): 16 raise RuntimeError( 17 f"batch device {batch.device} does not match model device {model_device}" 18 ) 19 20batch = torch.randn(4, 3) 21try: 22 require_same_device(torch.device("cuda"), batch) 23except RuntimeError as error: 24 print("caught:", error)
Output
1caught: batch device cpu does not match model device cuda

A small training example, step by step

Make the example concrete. Suppose you're training an access-ticket model that predicts whether a request should be answered, escalated, or blocked.

  1. The dataloader reads a batch of token IDs on the CPU.
  2. The batch is copied to device memory.
  3. The model weights already live on the GPU.
  4. PyTorch launches matmul, attention, and loss kernels on the GPU.
  5. Backward pass produces gradients on the GPU.
  6. The optimizer updates weights on the GPU.
  7. Only when you log a scalar or save results back to disk does the CPU need some of that state again.

That's why CUDA bugs often look strange at first. The Python code line you wrote and the GPU work it triggered are related, but they don't run in one shared place or finish at the same instant.

This complete stochastic-gradient-descent training step keeps the model, features, labels, logits, loss, and gradients on device until the final scalar is brought back for logging:

one_ticket_training_step.py
1import math 2 3import torch 4import torch.nn as nn 5import torch.nn.functional as F 6 7torch.manual_seed(7) 8device = torch.device("cuda" if torch.cuda.is_available() else "cpu") 9model = nn.Linear(8, 3).to(device) 10optimizer = torch.optim.SGD(model.parameters(), lr=0.1) 11features = torch.randn(4, 8, device=device) 12labels = torch.tensor([0, 2, 1, 0], device=device) 13 14optimizer.zero_grad() 15logits = model(features) 16loss = F.cross_entropy(logits, labels) 17loss.backward() 18optimizer.step() 19logged_loss = loss.detach().cpu().item() 20 21print("step device:", device) 22print("finite loss:", math.isfinite(logged_loss))
Output
1step device: cuda 2finite loss: True

The rendered output shows the configured NVIDIA path. The training-step structure stays the same across environments, but this lesson's output should model the accelerator run you are aiming for.

The same idea as a small table:

StepCPU sideGPU sideCommon beginner mistake
batch readcollator builds tensorsnothing yetassuming data is already on GPU
device copylaunch host-to-device transferreceives batch in device memorycopying every tiny tensor separately
forwardqueues layer callsexecutes kernelsmodel on GPU, batch on CPU
backwardlaunches autograd workcomputes gradientsOOM because activations were ignored
loggingasks for loss valuemay still be finishing kernels.item() every step hides synchronization cost

Those five rows are the core CUDA path: assemble on CPU, copy once, compute on GPU, and synchronize only at an intentional boundary.

Asynchronous execution and hidden sync points

One reason CUDA feels confusing is that the CPU usually launches GPU work asynchronously.[2]Reference 2CUDA semantics.https://docs.pytorch.org/docs/2.12/notes/cuda.html That means:

  • Python may continue before the GPU finishes the queued kernels.
  • timing a block with a naive host timer can under-report real GPU time
  • operations that need a CPU value force the host to wait for completion

Common sync points include:

  • loss.item() when loss is a CUDA tensor
  • tensor.cpu(), including the tensor.cpu().numpy() path used for NumPy analysis
  • logging or printing that materializes a CUDA value on the CPU
  • explicit torch.cuda.synchronize()
  • tensor.tolist() and booleanization that forces a CUDA value onto the host (for example using a CUDA tensor in a Python if)
  • some distributed collectives that wait for peer ranks

Calling .numpy() directly on a CUDA tensor isn't the route back to NumPy: move it to CPU first. This explicit boundary is a useful place to control logging frequency:

logging_boundary.py
1import torch 2 3device = torch.device("cuda" if torch.cuda.is_available() else "cpu") 4loss = torch.tensor(2.5, device=device) 5logged_loss = loss.detach().cpu().item() 6 7print(f"reported loss: {logged_loss:.1f}")
Output
1reported loss: 2.5

On a CUDA device, the .cpu() call above waits until data needed for the copy is ready. That's why a loop can look fast until you add "just one print."

A timing trap you should recognize by hand

Suppose one forward pass queues 40 ms of GPU work, but the CPU finishes launching it in 2 ms.

  • A naive timer wrapped only around the Python call might report about 2 ms.
  • A synchronized timer reports the real end-to-end GPU time: about 40 ms.

That mismatch isn't a rounding error. It changes the engineering conclusion.

  • If you believe the 2 ms number, you may think the GPU is extremely fast and the bottleneck must be elsewhere.
  • If you measure the real 40 ms number, you may correctly conclude that sequence length, batch size, or kernel efficiency still need work.

Beginner CUDA debugging should always ask: did the measurement include synchronization, or did it only measure kernel launch overhead?

Side-by-side host and GPU timelines: a naive host timer stops after 2 ms of kernel launches while GPU work still runs, versus a synchronized timer that waits and reports the real 40 ms step.
Asynchronous launch makes the host timer lie unless you synchronize. The CPU can finish queuing work quickly while the GPU is still busy with the real tensor math.

For real CUDA measurements, PyTorch recommends CUDA events or explicit synchronization around host timers.[2]Reference 2CUDA semantics.https://docs.pytorch.org/docs/2.12/notes/cuda.html Warm up the operation before recording steady-state work because first execution can include one-time setup costs. This script uses events when CUDA is available and keeps a runnable CPU fallback:

honest_matmul_timing.py
1import torch 2 3device = torch.device("cuda" if torch.cuda.is_available() else "cpu") 4x = torch.randn(128, 128, device=device) 5 6if device.type == "cuda": 7 for _ in range(3): 8 y = x @ x 9 torch.cuda.synchronize() 10 11 start = torch.cuda.Event(enable_timing=True) 12 end = torch.cuda.Event(enable_timing=True) 13 start.record() 14 for _ in range(10): 15 y = x @ x 16 end.record() 17 torch.cuda.synchronize() 18 print("measured with CUDA events:", start.elapsed_time(end) >= 0) 19else: 20 y = x @ x 21 print("CUDA events need CUDA; fallback result shape:", tuple(y.shape))
Output
1measured with CUDA events: True

Reading nvidia-smi without over-trusting it

nvidia-smi is useful, but it isn't a full profiler. PyTorch also uses a caching allocator, so memory visible in nvidia-smi can include reserved memory that's not currently occupied by live tensors.[3]Reference 3nvidia-smi documentationhttps://docs.nvidia.com/deploy/nvidia-smi/index.html[2]Reference 2CUDA semantics.https://docs.pytorch.org/docs/2.12/notes/cuda.html

Use it for:

  • checking whether the process attached to the GPU
  • checking rough process and device memory footprint
  • spotting obvious OOM pressure
  • seeing rough utilization snapshots

Don't use it as your only answer for:

  • kernel-level bottlenecks
  • whether dataloading is the issue
  • whether synchronization is killing throughput
  • whether the GPU is compute-bound or memory-bound

For code-level memory checks, separate live tensor bytes from allocator reservations. memory_allocated() tracks memory occupied by tensors. memory_reserved() tracks the larger pool managed by PyTorch's caching allocator. That pool can include unused memory kept for fast reuse, which is why nvidia-smi can report more memory than your live tensors occupy.[2]Reference 2CUDA semantics.https://docs.pytorch.org/docs/2.12/notes/cuda.html

allocator_counter_check.py
1import torch 2 3if torch.cuda.is_available(): 4 before_allocated = torch.cuda.memory_allocated() 5 tensor = torch.ones(1024, 1024, device="cuda") 6 after_allocated = torch.cuda.memory_allocated() 7 after_reserved = torch.cuda.memory_reserved() 8 print("live tensor allocation increased:", after_allocated > before_allocated) 9 print("allocator reserved at least live bytes:", after_reserved >= after_allocated) 10else: 11 print("CUDA allocator counters need an accessible CUDA device")
Output
1live tensor allocation increased: True 2allocator reserved at least live bytes: True

At the beginning, nvidia-smi, correct device placement, and these counters catch a large share of broken setups. Detailed profiling comes later.

First CUDA mistakes in training loops

1. Model on GPU, batch on CPU

  • Symptom: device mismatch error on forward pass.

  • Cause: weights and input tensors are on different devices.

  • Fix: move the whole batch, not a single field.

2. OOM on the first real batch

  • Symptom: the script starts, maybe even builds the model, then fails on the forward or backward pass.

  • Cause: activations and optimizer state push total memory over the card limit. Parameters alone aren't the full bill.

  • Fix: shrink per-step batch size first. If you need to preserve effective batch size, accumulate gradients across several smaller steps. Reduce sequence length or enable mixed precision when the task allows it. Activation checkpointing (recompute activations in backward instead of storing them) is the next named lever on transformer workloads. Read the failing allocation size in the CUDA OOM message before guessing; empty_cache() only returns unused reserved blocks to the driver and rarely fixes a true peak that still exceeds free memory.

Memory lever: The first two knobs reduce the number of token positions in a batch. Halving batch size halves that count; halving sequence length does too. Attention score tensors can drop faster when sequence length shrinks because they have two token axes. Parameter + Adam state is a useful floor (see the memory example later), but activations often dominate transformers: attention scores alone scale as O(B · H · T²) in the score tensor before values are mixed.

activation_position_budget.py
1batch_size = 32 2sequence_length = 128 3 4def positions(batch: int, tokens: int) -> int: 5 return batch * tokens 6 7baseline = positions(batch_size, sequence_length) 8for name, batch, tokens in [ 9 ("baseline", batch_size, sequence_length), 10 ("half batch", batch_size // 2, sequence_length), 11 ("half length", batch_size, sequence_length // 2), 12]: 13 ratio = positions(batch, tokens) / baseline 14 print(f"{name:11s}: {ratio:.1%} of token positions")
Output
1baseline : 100.0% of token positions 2half batch : 50.0% of token positions 3half length: 50.0% of token positions

3. Slow loop despite high GPU memory usage

  • Symptom: the GPU memory is full enough to look "active," but throughput is poor.

  • Cause: the bottleneck may be dataloading, synchronization, small batch size, or repeated host-device copies.

  • Fix: check whether the data pipeline feeds the GPU fast enough before assuming the math kernels are the problem. The standard first host→device throughput habit is pinned host memory plus non-blocking copies:

pinned_host_transfer.py
1# DataLoader(..., pin_memory=True) keeps CPU batches in page-locked memory 2# so the DMA engine can copy while the CPU prepares the next batch. 3batch = next(iter(loader)) 4features = batch["token_features"].to(device, non_blocking=True) 5labels = batch["labels"].to(device, non_blocking=True) 6# non_blocking helps only when the source is pinned and you don't immediately 7# force a sync (for example by calling .item() on a result). 8print("features device:", features.device, "labels device:", labels.device)

4. Timing without synchronization

  • Symptom: a kernel appears to take almost no time.

  • Cause: the timer stopped before queued CUDA work completed.

  • Fix: warm up the operation, then synchronize before starting and after enqueueing the measured work, or use CUDA events.

What to understand before writing custom kernels

You don't need Triton or CUDA C++ to start training models, but you should already understand:

  • why GPUs help matrix-heavy workloads
  • why tensor placement is explicit
  • why device memory is limited and precious
  • why copies and sync points can dominate step time
  • why "GPU utilization" alone isn't a diagnosis

That foundation makes later topics less mysterious:

  • mixed precision
  • FlashAttention
  • FSDP and ZeRO
  • tensor parallelism
  • custom kernels

Self-check before bigger training runs

Answer these before moving on.

Why can model(x) return before GPU work is done?

Answer

CUDA launch is asynchronous, so Python can continue while kernels are still queued or running.

Why can a training loop OOM even when weights fit?

Answer

Because activations, gradients, optimizer state, and buffers also consume device memory.

Why can one innocent print(loss.item()) change timing?

Answer

Because .item() forces synchronization, so the CPU waits for the GPU result before printing.

What must match before a forward pass works?

Answer

Model parameters and input batch must be on compatible devices.

Your batch tensor is on cpu, your model weights are on cuda:0, and the first forward pass crashes. What is the first fix?

Answer

Move the batch to the same device as the model before the forward pass, typically with batch = batch.to(device). The strong answer should also note that device placement must stay consistent for every tensor the model touches.

What to remember

  • CUDA is an execution and memory model, not a speed checkbox.
  • The CPU orchestrates. The GPU executes dense parallel math.
  • Host RAM and device memory are different places with real transfer costs.
  • PyTorch queues CUDA work asynchronously, so .item() and .cpu() can stall the host.
  • OOM errors usually mean the full training footprint doesn't fit, not model weights alone.

If that picture feels solid, you're ready to reason about training loops on accelerators instead of treating the GPU as an opaque speed device.

Use this checklist as the handoff artifact: run the device check, measure one operation with explicit synchronization, and write which memory terms can trigger OOM.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.An access-ticket model is training with its model weights and batches already on cuda:0. Profiling shows the forward and backward passes are dominated by large matrix multiplies and attention kernels, while dataloading and logging are minor. What conclusion follows?

Correct answer: A faster CPU may reduce orchestration overhead, but the dominant dense tensor math is running as GPU kernels, so the main speed work is on the GPU-side workload.

The CPU launches work and handles orchestration, but large batched tensor operations run as GPU kernels once tensors are on the CUDA device. If dataloading and logging are not the bottleneck, a faster CPU will not remove the main GPU-side cost.

2.In a CUDA training loop, adding print(loss.item()) after every batch makes the measured step time jump. What changed?

Correct answer: The print path asked for a CPU scalar, so PyTorch had to wait for queued GPU work to finish and copy the loss value back to host memory.

CUDA launches are asynchronous, so Python can run ahead of queued GPU kernels. Requesting a Python scalar with .item() is a synchronization point: the host must wait until the needed GPU work is complete and then copy the scalar back.

3.nvidia-smi lists an NVIDIA GPU, but this Python process prints torch.version.cuda as None and torch.cuda.is_available() as False. Which conclusion should you draw first?

Correct answer: The driver can see the GPU, but this environment is likely using a CPU-only or incompatible PyTorch build, so install a compatible CUDA-enabled build.

nvidia-smi reports what the NVIDIA driver can see. PyTorch separately reports whether the current Python environment was built with and can access CUDA. A visible GPU plus torch.version.cuda is None points first to the PyTorch environment, not model placement.

4.For a simplified full-precision Adam setup with 1,000,000,000 parameters, count 4 bytes for weights, 4 bytes for gradients, and 8 bytes for Adam moments per parameter. What is the parameter-related memory floor before activations and temporary buffers?

Correct answer: 14.90 GiB

Each parameter needs 16 bytes in this simplified count: 4 for the weight, 4 for its gradient, and 8 for Adam's two moments. For 1 billion parameters, that is 16,000,000,000 bytes, or about 14.90 GiB, before activations, temporary buffers, and allocator overhead.

5.During a training step on cuda:0, a batch dictionary contains token_features, attention_mask, and labels. The model and token_features are on cuda:0, but attention_mask and labels remain on CPU. What should the loop do before the forward pass and loss computation?

Correct answer: Move every tensor the model or loss will touch to the same device as the model, not the main feature tensor alone.

Device compatibility applies to every tensor used by the model or loss, including masks and labels. Synchronization doesn't move data, and leaving part of the batch on CPU can still trigger a device-mismatch failure even if the main feature tensor is on CUDA.

6.One forward pass queues about 40 ms of GPU work, but the CPU finishes launching it in about 2 ms. A host timer wrapped only around the Python call reports 2 ms. What measurement change gives the meaningful GPU time?

Correct answer: Warm up, then use CUDA events or synchronize around the timed region so the timer includes queued GPU work completing.

The 2 ms host measurement mostly captures enqueue overhead, not completion of the GPU kernels. To time CUDA work honestly, the measurement must include synchronization or use CUDA events, and warmup avoids one-time setup costs contaminating steady-state timing.

7.nvidia-smi shows a PyTorch process using much more GPU memory than torch.cuda.memory_allocated() reports for live tensors. Which interpretation matches PyTorch's allocator behavior?

Correct answer: PyTorch may have reserved a larger caching-allocator pool than the bytes currently occupied by live tensors.

memory_allocated() tracks bytes occupied by live tensors. memory_reserved() tracks the larger pool held by PyTorch's caching allocator, and nvidia-smi can reflect that reserved pool even when some of it's unused and kept for fast reuse.

8.A model loads on an 8 GiB GPU but OOMs on the first backward pass for sequences of length 128 and batch size 32. You need to keep the effective batch size near 32. What first change targets the likely training memory cause?

Correct answer: Run smaller per-step batches and accumulate gradients, and consider shorter sequences because activations add to the parameter footprint.

A model that loads can still OOM during forward or backward because activations, gradients, optimizer state, temporary buffers, and allocator overhead add to the memory footprint. Smaller per-step batches reduce activation pressure, and gradient accumulation can preserve the effective batch size across several smaller steps.

9.GPU memory is mostly allocated, but examples per second are poor. The loop copies many small tensors each step and prints CUDA losses frequently. What should you investigate before blaming the matrix kernels?

Correct answer: Check dataloading, synchronization points, batch size, and repeated host-device copies because high memory use doesn't prove the GPU is compute-bound.

High allocated memory doesn't prove the GPU is doing efficient compute. Poor throughput can come from dataloading stalls, accidental synchronization, small batches that fail to keep kernels large, or repeated host-device copies.

9 questions remaining.

Next Step
Continue to MPS & Metal for ML on Mac

CUDA gave you accelerator basics in the NVIDIA world: host orchestration, device placement, synchronization, and memory pressure. The MPS chapter now maps those same ideas onto Apple silicon so Mac users can follow later training lessons with the right backend names and debugging checks.

PreviousNumPy and Tensor Shapes
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

CUDA Programming Guide.

NVIDIA · 2026

https://docs.nvidia.com/cuda/cuda-programming-guide/

CUDA semantics.

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/2.12/notes/cuda.html

nvidia-smi documentation

NVIDIA · 2026

https://docs.nvidia.com/deploy/nvidia-smi/index.html

Get Started.

PyTorch Contributors · 2026

https://pytorch.org/get-started/locally/

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.