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 178 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/17
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 TuningTraining Run OperationsReward 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 Agent System Design
🎤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

Follow one access-ticket batch from CPU memory into CUDA kernels. Learn thread and memory hierarchy, safe device placement, honest timing, and first-line diagnosis for setup, OOM, and throughput failures.

15 min read
Learning path
Step 5 of 178 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.

This builds on the NumPy shape contracts. An access-ticket classifier labels each ticket answer, escalate, or block. A training-sized batch looks like (32, 128, 768). CUDA indexing is easier to see on a miniature of that same task: four tickets, eight token positions, 16 features, shape (4, 8, 16). Axis names stay the same as in the NumPy lesson. Residence and scale change.

The new constraint is physical. Those numbers live in CPU memory or GPU memory, and an operation fails if its tensors disagree about which.

CUDA is NVIDIA's platform and programming model for running parallel work on NVIDIA GPUs. It doesn't turn every Python line into GPU code. Python still runs on the CPU, while PyTorch launches GPU functions called kernels for tensor operations whose data lives on a CUDA device.[1]Reference 1CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/

Platform path

If you train on a Mac with Apple silicon, continue through this lesson for the shared accelerator concepts, then use MPS & Metal for ML on Mac for its backend and memory differences.

Eight tokens onto two blocks

A CPU is tuned for low-latency control flow and varied work. A GPU is tuned for high throughput when many data items need similar arithmetic. Large matrix multiplications, convolutions, and attention scores are good GPU work. A tiny tensor, a branch-heavy Python loop, or a copy that happens every line may be faster on the CPU, because launch and transfer overhead can exceed the useful math.

Part of a training stepCPU host jobGPU device job
data preparationread, tokenize, pad, and assemble a batchno work yet
device transferrequest a copyreceive tensor data in device memory
forward and backwardlaunch PyTorch operationsexecute tensor kernels
reportingrequest a Python number or file writefinish and return needed values

That split shows up inside one kernel launch. A thread handles one logical slice of work. Threads are grouped into thread blocks, and all blocks launched for one kernel form a grid. CUDA schedules each whole block onto one streaming multiprocessor (SM), a hardware processor inside the GPU. Several blocks may be active on one SM, and CUDA doesn't promise which block runs first.[1]Reference 1CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/

Take the eight token positions in the miniature ticket batch. Two blocks with four threads each is a convenient teaching size. Each thread computes a unique global index:

global_id=block_id×threads_per_block+thread_id\text{global\_id} = \text{block\_id} \times \text{threads\_per\_block} + \text{thread\_id}global_id=block_id×threads_per_block+thread_id

For block 1, thread 2, the global index is 1 × 4 + 2 = 6, so that thread handles token position T6. This Python trace checks all eight assignments before the CUDA vocabulary grows:

map-token-positions.py
1threads_per_block = 4 2blocks = 2 3 4for block_id in range(blocks): 5 positions = [] 6 for thread_id in range(threads_per_block): 7 global_id = block_id * threads_per_block + thread_id 8 positions.append(f"T{global_id}") 9 print(f"block {block_id}: {positions}")
Output
1block 0: ['T0', 'T1', 'T2', 'T3'] 2block 1: ['T4', 'T5', 'T6', 'T7']
Eight access-ticket token positions mapped onto two CUDA blocks of four threads, then one 32-lane warp for block 0 with only lanes 0-3 active (T0-T3) and lanes 4-31 idle.
Each of the eight token positions gets one global id. Block 0 still occupies a 32-lane warp, so 28 lanes stay idle. Real kernels pick a thread count that is a multiple of 32 so the last warp isn't hollow.

An NVIDIA warp contains 32 threads from one block. Warp lanes execute through a single-instruction, multiple-thread (SIMT) model. If lanes take different branches, CUDA masks inactive lanes while it executes each required path, so divergence can waste throughput.[1]Reference 1CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/

The four-thread teaching block is legal, and it still occupies a full warp. CUDA fills that warp with consecutive thread ids from the same block, so 4 lanes do ticket work and 28 lanes sit unused for the whole launch. That's a teaching cost, not a production default.

A launch uses this stack, from the Python call down to one thread:

LevelMeaningBeginner question
PyTorch operationa request such as x * 2 or x @ wIs the tensor on CUDA?
kernelGPU function launched for that operationIs the work large enough to justify a launch?
gridevery block in one launchHow much work exists?
blockthreads that run on one SM and can cooperateWhich slice of work stays together?
warp32 threads scheduled togetherAre branches, or a short last warp, leaving lanes idle?
threadone logical worker with its own indexWhich data item does it handle?

PyTorch's matrix-multiply kernels use optimized tiling that is more complex than one thread per token. The eight-position trace teaches CUDA's indexing and scheduling vocabulary without pretending to describe an optimized library kernel exactly.

Those launches only happen if this process can see a CUDA device. The next check is whether the driver, the PyTorch build, and the GPU agree.

Check driver, PyTorch build, and device

Four software layers often get collapsed into "my CUDA version." Keep them separate. The NVIDIA System Management Interface command, nvidia-smi, reads driver and GPU state; nvcc is NVIDIA's CUDA compiler:

SignalWhat it tells youWhat it doesn't prove
nvidia-smiNVIDIA driver can see a GPU. The CUDA Version header (documented as CUDA UMD Version; the older CUDA Version label is deprecated) is the latest CUDA version that driver supportswhich CUDA toolkit is installed, or whether this Python has a CUDA-enabled PyTorch build
torch.__version__installed PyTorch version and often its build suffixwhether GPU access works
torch.version.cudaCUDA runtime version used to build this PyTorch package, or None for a CPU-only buildwhether driver and device are reachable
torch.cuda.is_available()current process can initialize and use CUDAwhether a particular workload fits or runs fast
nvcc --versionversion of local CUDA toolkit compiler, when installedwhich runtime a prebuilt PyTorch package uses

NVIDIA's nvidia-smi manual says that CUDA UMD Version is the latest CUDA version the driver supports, and that this is usually, but not always, the installed toolkit version.[2]Reference 2nvidia-smi documentationhttps://docs.nvidia.com/deploy/nvidia-smi/index.html It never proves that this Python process loaded a CUDA-enabled PyTorch build. PyTorch's installer offers builds for supported compute platforms, and its verification step uses torch.cuda.is_available().[3]Reference 3Get Started.https://pytorch.org/get-started/locally/ Pick the current command from that selector instead of copying an old wheel URL from a tutorial.

For ordinary prebuilt PyTorch use, start with a compatible NVIDIA driver and the selected PyTorch package. A local toolkit and nvcc become relevant when you build PyTorch from source or compile custom CUDA extensions. Version strings don't need to be identical; the driver must support the runtime used by the package.

Run the driver check first, then inspect the Python environment:

terminal
1nvidia-smi 2python3 - <<'PY' 3import torch 4 5print("PyTorch:", torch.__version__) 6print("PyTorch CUDA runtime:", torch.version.cuda) 7print("CUDA available:", torch.cuda.is_available()) 8 9if torch.cuda.is_available(): 10 print("device:", torch.cuda.get_device_name(0)) 11 print("compute capability:", torch.cuda.get_device_capability(0)) 12 print("build architectures:", torch.cuda.get_arch_list()) 13PY

The compute capability pair describes hardware features supported by a GPU generation. get_arch_list() reports architectures included in the PyTorch build. Read failures from the first layer that disagrees:

Observed stateLikely boundaryNext check
nvidia-smi failsdriver, hardware, or container accessdriver installation and device exposure
nvidia-smi works, torch.version.cuda is NoneCPU-only PyTorch packagereinstall from official CUDA selector
PyTorch has a CUDA runtime, but availability is Falsedriver compatibility or process visibilitydriver support, container GPU access, CUDA_VISIBLE_DEVICES
availability is True, but kernel reports unsupported architecturePyTorch build is too old for GPUinstall a build that includes current GPU architecture
availability is True and device name is correctbasic stack workstest placement, memory, and timing

CUDA_VISIBLE_DEVICES changes the GPU indexes visible inside the process. If physical GPU 1 is the only visible device, PyTorch may call it cuda:0. Inspect tensor.device and device names inside the running process instead of assuming host indexes survive a mask.

Once the stack answers "yes," the miniature ticket batch still has to move.

Move the ticket batch once

Return to the (4, 8, 16) ticket tensor. Shape stays constant during a device transfer. Residence changes from host memory to CUDA device memory.

On a discrete NVIDIA GPU, host-to-device (H2D) means copying data from CPU memory to GPU memory. Device-to-host (D2H) is the return path. Keep model weights, batch tensors, activations, gradients, and optimizer state on the GPU through the hot part of a training step. Bring back the small value needed for reporting.

Host versus CUDA memory for one access-ticket step, plus a 16-byte-per-parameter Adam stack: 4 bytes weight, 4 bytes gradient, and 8 bytes of two moment tensors, totaling 14.90 GiB at one billion parameters before activations.
The miniature batch is 2,048 bytes on the H2D copy. Adam's parameter-related floor is 16 bytes per weight, 14.90 GiB at one billion parameters, before activations. The copy is not the out-of-memory (OOM) event.

This runnable step stays small enough for any CUDA card, and it still exercises the full hot path. It averages eight token vectors into one 16-feature vector per ticket, predicts the three ticket classes, computes loss, runs backward, and updates weights. Labels are 0 = answer, 1 = escalate, 2 = block.

one-cuda-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") 9 10model = nn.Linear(16, 3).to(device) 11optimizer = torch.optim.SGD(model.parameters(), lr=0.1) 12 13cpu_batch = { 14 "token_features": torch.randn(4, 8, 16), 15 "labels": torch.tensor([0, 2, 1, 0]), 16} 17batch = {name: tensor.to(device) for name, tensor in cpu_batch.items()} 18 19optimizer.zero_grad() 20ticket_vectors = batch["token_features"].mean(dim=1) 21logits = model(ticket_vectors) 22loss = F.cross_entropy(logits, batch["labels"]) 23loss.backward() 24optimizer.step() 25 26model_device = next(model.parameters()).device 27reported_loss = loss.detach().cpu().item() 28print("selected accelerator:", device.type == "cuda") 29print("model and batch agree:", model_device == batch["token_features"].device) 30print("ticket vectors:", tuple(ticket_vectors.shape)) 31print("logits:", tuple(logits.shape)) 32print("finite loss:", math.isfinite(reported_loss))

On an accessible NVIDIA GPU, the first two lines should report True. A CPU-only machine reports False for the accelerator line but still validates the tensor and training logic. In either case, ticket vectors must be (4, 16) and logits must be (4, 3).

Device placement applies to every tensor an operation touches. Moving features but leaving labels on CPU still fails during a CUDA loss calculation. This deliberate failure uses a real mixed-device forward pass when CUDA exists and explains the validation limit otherwise:

reproduce-device-mismatch.py
1import torch 2import torch.nn as nn 3 4if torch.cuda.is_available(): 5 cuda_model = nn.Linear(16, 3).to("cuda") 6 cpu_features = torch.randn(4, 16) 7 try: 8 cuda_model(cpu_features) 9 except RuntimeError: 10 print("caught a real CPU/CUDA device mismatch") 11 else: 12 raise AssertionError("expected a mixed-device forward pass to fail") 13else: 14 print("CUDA unavailable: real mixed-device failure wasn't executed")

The symptom is an error saying tensors or arguments aren't on the same device. Cause is placement, not shape. Move every participating tensor to the model's device before the forward or loss call.

The miniature step can succeed and a training-sized batch can still fail. The missing piece is how much extra memory a real forward and backward pass asks for.

Count memory before the first big batch

CUDA exposes several storage levels. They aren't interchangeable:

StorageScopeTypical training data
host RAMCPU processdataset objects, decoded examples, CPU batches
device global memory, often called video RAM (VRAM) or high-bandwidth memory (HBM)all SMs on one GPUweights, activations, gradients, optimizer state
L2 and L1 cachesGPU hardwarerecently accessed device data
shared memorythreads in one blocktiles reused inside a kernel
registersone threadcounters, addresses, and small working values

PyTorch allocates model tensors in device global memory. Optimized kernels decide when to reuse tiles through caches, shared memory, or registers. Calling .to("cuda") doesn't place a whole tensor in a register or in shared memory. An optimizer adds its own long-lived state during training.

The miniature batch itself contains 4 × 8 × 16 = 512 float32 values, only 2,048 bytes. The training-sized (32, 128, 768) float32 batch that the Mac lesson continues is 32 × 128 × 768 × 4 = 12,582,912 bytes, exactly 12.0 MiB of input features. Training memory still grows quickly because input is one term among several:

training memory≈weights+activations+gradients+optimizer state+temporary workspaces\text{training memory} \approx \text{weights} + \text{activations} + \text{gradients} + \text{optimizer state} + \text{temporary workspaces}training memory≈weights+activations+gradients+optimizer state+temporary workspaces

For a simplified full-precision Adam budget, one parameter may need 4 bytes for its weight, 4 for its gradient, and 8 for Adam's two running statistics. One billion parameters therefore have a parameter-related floor of 16 billion bytes. Activations, temporary buffers, allocator overhead, and any extra parameter copies still sit outside that floor.

adam-memory-floor.py
1parameters = 1_000_000_000 2bytes_per_parameter = 4 + 4 + 8 3total_bytes = parameters * bytes_per_parameter 4feature_bytes = 32 * 128 * 768 * 4 5 6print(f"bytes per parameter: {bytes_per_parameter}") 7print(f"parameter-related floor: {total_bytes / (1024 ** 3):.2f} GiB") 8print(f"training-sized ticket features: {feature_bytes / (1024 ** 2):.1f} MiB") 9print("activations and temporary work: add more memory")
Output
1bytes per parameter: 16 2parameter-related floor: 14.90 GiB 3training-sized ticket features: 12.0 MiB 4activations and temporary work: add more memory

A model can load and still hit an OOM failure on its first forward or backward pass. Loading proves that current state fits. It doesn't prove that activations and gradients for a real batch fit.

For the running text batch, two first-line levers reduce activation work:

activation-position-budget.py
1batch_size = 32 2sequence_length = 128 3baseline = batch_size * sequence_length 4 5for name, batch, tokens in [ 6 ("baseline", 32, 128), 7 ("half batch", 16, 128), 8 ("half length", 32, 64), 9]: 10 share = batch * tokens / baseline 11 print(f"{name:11s}: {share:.0%} of token positions")
Output
1baseline : 100% of token positions 2half batch : 50% of token positions 3half length: 50% of token positions

Halving sequence length can reduce attention-score storage faster because a score tensor has two token axes, commonly shaped (B, H, T, T). Halving batch size cuts its B term in half; halving T cuts its T × T term to one quarter. Later attention lessons derive that shape in full. Feature width D = 768 doesn't change that token-position count.

Mixed precision is another memory and throughput tool, but it isn't a safe synonym for calling .half() on everything. PyTorch's automatic mixed precision (AMP) uses torch.autocast(device_type="cuda") to choose an operation-specific dtype. FP16 (16-bit floating point) training may need torch.amp.GradScaler("cuda") to prevent small gradients from underflowing, and some models overflow in FP16.[4]Reference 4Automatic Mixed Precision package - torch.amphttps://docs.pytorch.org/docs/stable/amp.html Confirm finite loss, finite gradients, and expected model quality. The dedicated mixed-precision lesson covers that workflow.

Ampere and newer NVIDIA GPUs can run some matmuls in TensorFloat-32 (TF32). Since PyTorch 1.12, torch.backends.cuda.matmul.allow_tf32 defaults to False, so a new install doesn't silently trade IEEE FP32 matmul accuracy for TF32 throughput. Enable it only after you've measured the speedup and checked quality.[5]Reference 5CUDA semantics.https://docs.pytorch.org/docs/2.13/notes/cuda.html

Memory pressure answers "does it fit?" It doesn't answer "how long did the GPU actually work?" CUDA's default async launch makes that second question easy to get wrong.

Measure asynchronous work honestly

PyTorch queues CUDA operations asynchronously by default. The CPU can finish a Python call while its GPU kernels are still waiting or running. Operations in the same CUDA stream keep their order, so later GPU work sees correct results without a host wait. A host-visible value or explicit synchronization makes the CPU wait.[5]Reference 5CUDA semantics.https://docs.pytorch.org/docs/2.13/notes/cuda.html

Suppose the ticket classifier's forward pass takes 40 ms on the GPU, but Python needs only 2 ms to queue it. A host timer around the call can report about 2 ms. That number measures launch time, not completed work.

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 CUDA event measurement that waits and reports the full 40 ms step.
The left timer stops when Python finishes launching work. The right timer waits for the CUDA event, so its 40 ms reading covers the device work that the performance question is asking about.

Warm up first because initial calls may include library setup or kernel selection. Then use CUDA events for device time, or synchronize around a host timer. This script uses events on CUDA and a normal completed CPU timer as a fallback:

honest-matmul-timing.py
1import time 2 3import torch 4 5device = torch.device("cuda" if torch.cuda.is_available() else "cpu") 6x = torch.randn(256, 256, device=device) 7 8for _ in range(3): 9 result = x @ x 10 11if device.type == "cuda": 12 torch.cuda.synchronize() 13 start = torch.cuda.Event(enable_timing=True) 14 end = torch.cuda.Event(enable_timing=True) 15 start.record() 16 for _ in range(10): 17 result = x @ x 18 end.record() 19 torch.cuda.synchronize() 20 elapsed_ms = start.elapsed_time(end) 21else: 22 start_time = time.perf_counter() 23 for _ in range(10): 24 result = x @ x 25 elapsed_ms = (time.perf_counter() - start_time) * 1000 26 27print("result shape:", tuple(result.shape)) 28print("completed timing is positive:", elapsed_ms > 0)

Common host waits include:

  • loss.item() for a Python scalar
  • tensor.cpu() before CPU analysis
  • tensor.cpu().numpy() before NumPy work
  • printing a CUDA tensor's values
  • torch.cuda.synchronize()

Those operations aren't bugs. They become performance bugs when they sit inside a hot loop more often than reporting or correctness requires.

Asynchrony also affects error location. A bad kernel may report its error on a later Python line that finally waits for the device. For one debugging reproduction, run with CUDA_LAUNCH_BLOCKING=1 to make CUDA calls synchronous and recover a more useful stack trace. Remove it before performance measurement because it changes execution behavior.[5]Reference 5CUDA semantics.https://docs.pytorch.org/docs/2.13/notes/cuda.html

When the ticket step is slow, empty, or dead, which boundary failed first?

Diagnose setup, memory, and throughput failures

nvidia-smi is a useful first observation, not a kernel profiler. Use it to confirm device visibility, process attachment, rough memory pressure, and utilization samples. PyTorch's caching allocator can hold unused blocks for reuse, so nvidia-smi may show more memory than live tensors occupy.[2]Reference 2nvidia-smi documentationhttps://docs.nvidia.com/deploy/nvidia-smi/index.html[5]Reference 5CUDA semantics.https://docs.pytorch.org/docs/2.13/notes/cuda.html

PyTorch separates two allocator views:

  • torch.cuda.memory_allocated() counts memory occupied by live tensors.
  • torch.cuda.memory_reserved() counts the larger pool managed by PyTorch's caching allocator.

torch.cuda.empty_cache() releases unused cached blocks for other applications. It doesn't free live tensors or increase memory available to the same PyTorch job, because that job could already reuse its cached blocks.[5]Reference 5CUDA semantics.https://docs.pytorch.org/docs/2.13/notes/cuda.html

Use symptom, cause, and next action together:

SymptomLikely causeFirst action
torch.cuda.is_available() is Falsedriver, visibility, or PyTorch build mismatchcompare nvidia-smi, torch.version.cuda, and process visibility
forward says tensors are on different devicesmodel and one batch field disagreemove every tensor used by model or loss to model device
model loads, backward OOMsactivations, gradients, optimizer state, or workspace exceed free memorylower per-step batch size, then sequence length; read allocation size in OOM message
effective batch must stay largesmaller steps change optimization batchaccumulate gradients across several smaller steps and scale loss correctly
loss or gradients become not-a-number (NaN) under FP16numerical overflow or invalid mixed-precision pathdisable AMP to reproduce, then use autocast and scaling with finite-value checks
host timer says a kernel took almost zero timeCPU timed enqueue onlywarm up and use CUDA events or explicit synchronization
memory bar is high but examples per second are lowallocation isn't utilization; data, copies, sync, or tiny kernels may stallinspect dataloading and host waits before blaming matrix kernels
GPU utilization repeatedly falls to zeroinput pipeline can't feed device steadilyprofile data loading, preprocessing, and transfer cadence
CUDA error points at an innocent later lineasynchronous error surfaced at next waitreproduce once with CUDA_LAUNCH_BLOCKING=1

Pinned host memory can speed H2D copies. DataLoader(..., pin_memory=True) returns batches in page-locked memory, and .to(device, non_blocking=True) lets the host continue without waiting for each transfer. Pinning uses a limited host-RAM pool, and real copy/compute overlap also depends on stream scheduling.[5]Reference 5CUDA semantics.https://docs.pytorch.org/docs/2.13/notes/cuda.html

The next snippet is a transfer-boundary fragment, not a full script. It assumes dataset and device already exist:

pinned-batch-transfer.py
1from torch.utils.data import DataLoader 2 3loader = DataLoader(dataset, batch_size=32, pin_memory=True) 4 5for features, labels in loader: 6 features = features.to(device, non_blocking=True) 7 labels = labels.to(device, non_blocking=True) 8 assert features.device == device and labels.device == device 9 # Forward, backward, and optimizer work stay on device.

Measure before and after. Pinned memory and non-blocking copies help a transfer bottleneck; they won't fix a kernel that is already compute-bound or an OOM caused by live tensors.

Preflight one training step

Use the complete training step as a small accelerator artifact. Record evidence for each boundary instead of writing "GPU works."

  1. Predict shapes before running: batch (4, 8, 16), pooled vectors (4, 16), logits (4, 3).
  2. Run nvidia-smi and the PyTorch environment check. Record driver, PyTorch runtime, device name, and availability separately.
  3. Run the training step. Confirm model, features, labels, logits, and loss use one device until reporting.
  4. Deliberately leave features on CPU while model is on CUDA. Capture the device-mismatch symptom, then restore .to(device).
  5. Time ten matrix multiplies with CUDA events. Compare that result with a naive host timer around the same loop.
  6. Write the terms that would make a larger run OOM: weights, activations, gradients, optimizer state, workspaces, and allocator overhead.

Compare your expected output with this table, then correct the first boundary that fails:

EvidencePass conditionIf it fails
shape trace(4, 8, 16) → (4, 16) → (4, 3)revisit reduction axis or linear-layer input width
device traceevery training tensor matches model devicemove missing batch field before operation that uses it
timing traceevent reports completed GPU workadd warmup and synchronization after end event
memory tracefull training footprint is namedadd activations, gradients, optimizer state, and temporary work

Check the reasoning without running code:

Why can model(x) return to Python before its CUDA kernels finish?

Answer

PyTorch queues CUDA work asynchronously. Python can continue after enqueueing while the device executes operations in stream order.

Why can a model fit in device memory and then OOM during backward?

Answer

Model loading accounts for current state, but training also needs activations, gradients, optimizer state, temporary workspaces, and allocator overhead.

Why can nvidia-smi show more memory than memory_allocated()?

Answer

PyTorch's caching allocator can reserve unused blocks for fast reuse. memory_allocated() counts live tensors, while nvidia-smi can reflect the larger reserved pool.

What does the CUDA value printed by nvidia-smi mean?

Answer

It's the latest CUDA version the installed driver supports (CUDA UMD Version; the older CUDA Version label is deprecated). It isn't proof that the same toolkit is installed or that this PyTorch environment can use CUDA.

Three contracts for the next lesson

A CUDA training step has three contracts:

  • Shape: each axis still means what the model expects.
  • Device: every tensor used together lives on a compatible device.
  • Time: measurements include the GPU work whose latency you intend to report.

The CPU prepares data and launches work. CUDA maps kernels onto grids, blocks, warps, and SMs. Device memory holds the long-lived training state, while kernels use caches, shared memory, and registers for smaller working sets. Copies and synchronization are explicit costs, so placement, OOM diagnosis, and timing all follow from the same execution path.

The Mac lesson keeps those contracts on the same access-ticket classifier, then changes the hardware picture: Apple silicon doesn't give you a separate VRAM pool copied over PCIe.

Complete the lesson

Mastery Check

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

1.A teaching kernel launches two blocks with four threads each. Which thread handles global position 6 under global_id = block_id * threads_per_block + thread_id?

Correct answer: Block 1, thread 2

For block 1 and thread 2, the global index is 1 * 4 + 2 = 6. Threads belong to blocks, and CUDA schedules each whole block onto one SM.

2.A 4-thread teaching block still occupies one CUDA warp. How many of that warp's 32 lanes stay unused for the whole launch?

Correct answer: 28 lanes stay unused.

A warp holds 32 consecutive thread ids from one block. A 4-thread block fills 4 lanes and leaves 28 unused. Warps don't mix threads from different blocks.

3.nvidia-smi sees a GPU and prints a CUDA version, but torch.version.cuda is None. What should you conclude first?

Correct answer: Driver sees the GPU, but this environment has a CPU-only PyTorch package with no CUDA runtime

The CUDA value in nvidia-smi (CUDA UMD Version) describes driver capability. torch.version.cuda is None points to a PyTorch build without a CUDA runtime, so use the official installer selector for a compatible CUDA-enabled package.

4.A miniature ticket batch has shape (4, 8, 16). Averaging over the token axis and passing the result through nn.Linear(16, 3) for answer, escalate, and block should produce which shapes?

Correct answer: Pooled vectors (4, 16), logits (4, 3)

Averaging over eight token positions removes the token axis while keeping four tickets and 16 features. The linear layer maps each 16-feature ticket vector to three class logits.

5.Model weights and features are on cuda:0, but labels used by cross-entropy remain on CPU. What should the training loop do?

Correct answer: Move labels to same device before loss calculation

Device compatibility applies to every tensor an operation touches, including integer labels and boolean masks. Synchronization waits for work but doesn't move tensor storage.

6.Python queues about 40 ms of CUDA work in 2 ms. A host timer around only the Python call reports 2 ms. Which measurement answers how long GPU work took?

Correct answer: Warm up, record CUDA events, then synchronize before reading elapsed event time

Asynchronous launch lets Python run ahead. CUDA events plus synchronization include completion of queued device work instead of reporting enqueue overhead.

7.Simplified FP32 Adam budget counts 4 bytes for weight, 4 for gradient, and 8 for two moment tensors. What is the floor for one billion parameters before activations and workspaces?

Correct answer: 14.90 GiB

Simplified count is 16 bytes per parameter. One billion parameters use 16 billion bytes, about 14.90 GiB, before activations, temporary buffers, and allocator overhead.

8.nvidia-smi reports much more process memory than torch.cuda.memory_allocated(). Which explanation fits PyTorch allocator?

Correct answer: PyTorch caching allocator may reserve unused blocks beyond the bytes occupied by live tensors

memory_allocated() tracks live tensors. memory_reserved() tracks the larger caching pool, which can stay visible to the driver even when some blocks are ready for reuse.

9.Model loads but OOMs during backward, and effective batch size must stay similar. Which first response is sound?

Correct answer: Shrink per-step batch and accumulate gradients correctly

Backward adds activation, gradient, optimizer, and workspace pressure. Smaller steps reduce peak activations; correct accumulation can preserve effective batch while other terms are inspected.

10.GPU memory is mostly reserved, but examples per second are low and utilization repeatedly falls to zero. Which investigation comes first?

Correct answer: Profile input stalls, transfers, synchronization, and kernel size

Reserved memory measures allocator state, not useful arithmetic. Input stalls, small kernels, unnecessary copies, and frequent host waits can all leave GPU underfed.

10 questions remaining.

Next Step
Continue to MPS & Metal for ML on Mac

You now have shape, device, memory, and timing contracts on a discrete NVIDIA GPU, using the same access-ticket classifier. The MPS lesson keeps those contracts and shows where Apple silicon changes the backend checks and replaces separate VRAM with unified memory.

PreviousNumPy and Tensor Shapes
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

CUDA Programming Guide.

NVIDIA · 2026

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

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/

Automatic Mixed Precision package - torch.amp

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/stable/amp.html

CUDA semantics.

PyTorch Contributors · 2026

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

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.