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 192 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/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics 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/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch 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/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData 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/27
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingModel 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 & AutoscalingDistributed Inference Data PlaneServing Benchmark EngineeringA/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
LearnInference & Production ScaleGEMM From Naive CUDA to Tensor Cores
🚀HardInference Optimization

GEMM From Naive CUDA to Tensor Cores

Build one matrix multiplication through coalesced scalar CUDA, shared-memory tiling, register blocking, Tensor Core MMA, and the boundary where async copies and CUTLASS take over.

21 min read
Learning path
Step 148 of 192 in the full curriculum
GPU Compilers and Kernel LanguagesLow-Precision GPU Programming

Personalize this lesson

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

A matrix multiplication can return every right number and still waste most of a GPU. One thread mapping scatters memory requests. A better mapping makes adjacent lanes touch adjacent values. Tiling then reuses those values on chip, while Tensor Cores change the arithmetic instruction itself.

The previous GPU compiler lesson separated source language, generated code, and target architecture. In the kernel engineering lab, memory access, occupancy, and synchronization became tuning decisions. Finally, the profiling lesson required correctness before speed. Keep all three contracts active here.

We'll build one operation, C=ABC = ABC=AB, through five kernels. General matrix-matrix multiplication (GEMM) names this family of products. Matrix AAA has shape [M,K][M,K][M,K], BBB has shape [K,N][K,N][K,N], and CCC has shape [M,N][M,N][M,N]. All lab buffers use row-major storage, so neighboring columns occupy neighboring addresses.

The complete CUDA source is available as gemm_lab.cu. Its kernels are intentionally educational. A production library has more schedules, layout transforms, epilogues, and hardware-specific paths than one chapter should hide inside a code listing.

Compute one output before launching a warp

Start with matrices small enough to multiply on paper:

A=[120−132],B=[2−11430]A = \begin{bmatrix}1 & 2 & 0 \\ -1 & 3 & 2\end{bmatrix}, \qquad B = \begin{bmatrix}2 & -1 \\ 1 & 4 \\ 3 & 0\end{bmatrix}A=[1−1​23​02​],B=​213​−140​​

AAA is 2×32 \times 32×3 and BBB is 3×23 \times 23×2, so CCC must be 2×22 \times 22×2. Entry C1,0C_{1,0}C1,0​ takes row 1 of AAA and column 0 of BBB:

C1,0=(−1)(2)+(3)(1)+(2)(3)=−2+3+6=7C_{1,0} = (-1)(2) + (3)(1) + (2)(3) = -2 + 3 + 6 = 7C1,0​=(−1)(2)+(3)(1)+(2)(3)=−2+3+6=7

Applying the same dot product to each output gives:

C=[47713]C = \begin{bmatrix}4 & 7 \\ 7 & 13\end{bmatrix}C=[47​713​]

GEMM performs MNKMNKMNK multiplications and about the same number of additions. Performance tools conventionally count 2MNK2MNK2MNK floating-point operations (FLOPs), including one multiply and one add for each inner-loop step. Our tiny product therefore counts 2⋅2⋅2⋅3=242 \cdot 2 \cdot 2 \cdot 3 = 242⋅2⋅2⋅3=24 FLOPs.

The script below reproduces the hand calculation and compares two traffic models. Its first model assumes every output reloads two input values for every kkk. An optimistic second model counts each input and output element once, giving a lower bound for a cold, standalone operation rather than a claim about a real cache.

gemm-hand-check.py
1A = [[1.0, 2.0, 0.0], [-1.0, 3.0, 2.0]] 2B = [[2.0, -1.0], [1.0, 4.0], [3.0, 0.0]] 3 4M, K, N = len(A), len(B), len(B[0]) 5C = [ 6 [sum(A[row][k] * B[k][col] for k in range(K)) for col in range(N)] 7 for row in range(M) 8] 9 10flops = 2 * M * N * K 11scalar_bytes = 4 * M * N * (2 * K + 1) 12unique_bytes = 4 * (M * K + K * N + M * N) 13 14print(f"C={C}") 15print(f"flops={flops}") 16print(f"scalar_load_model={flops / scalar_bytes:.3f} FLOP/B") 17print(f"perfect_reuse_bound={flops / unique_bytes:.3f} FLOP/B")
Output
1C=[[4.0, 7.0], [7.0, 13.0]] 2flops=24 3scalar_load_model=0.214 FLOP/B 4perfect_reuse_bound=0.375 FLOP/B

Those intensities are small because the matrices are small. For a square FP32 GEMM with M=N=K=nM=N=K=nM=N=K=n, the unique-byte model becomes:

Iideal=2n34(n2+n2+n2)=n6 FLOP/BI_{\text{ideal}} = \frac{2n^3}{4(n^2+n^2+n^2)} = \frac{n}{6}\ \text{FLOP/B}Iideal​=4(n2+n2+n2)2n3​=6n​ FLOP/B

Large square GEMMs can have enough reuse to become compute-bound, but only if the kernel captures that reuse. The roofline model bounds attainable compute rate by the lower of peak arithmetic throughput and memory bandwidth multiplied by arithmetic intensity.[1]Reference 1Roofline: An Insightful Visual Performance Model for Multicore Architectureshttps://doi.org/10.1145/1498765.1498785 It can't tell us which tile or layout wins. It tells us whether reducing bytes can still move the ceiling.

Why is 0.375 FLOP/B an optimistic bound for the tiny product rather than a measured intensity?

Answer

It counts every A, B, and C element exactly once. A real kernel may fetch cache lines it only partly uses, reload evicted data, or move padding and metadata. A warm cache can also reduce DRAM traffic for one launch, so measure device traffic and record cache conditions.

A correct thread can still request the wrong addresses

The first CUDA kernel assigns one thread to one output. Each thread runs the same KKK-step dot product:

gemm_lab.cu
1__global__ void gemm_scalar_strided(const float* A, const float* B, float* C, 2 int M, int N, int K) { 3 int row = blockIdx.x * blockDim.x + threadIdx.x; 4 int col = blockIdx.y * blockDim.y + threadIdx.y; 5 if (row >= M || col >= N) return; 6 7 float acc = 0.0f; 8 for (int k = 0; k < K; ++k) { 9 acc += A[row * K + k] * B[k * N + col]; 10 } 11 C[row * N + col] = acc; 12}

CUDA linearizes threadIdx.x first. Adjacent lanes therefore change row while holding col fixed in this mapping. At one inner-loop value kkk, those lanes read A[row * K + k] with a stride of KKK and store C[row * N + col] with a stride of NNN. The values are mathematically correct, but a warp's addresses are scattered across memory.

Swap ownership so threadIdx.x selects col:

gemm_lab.cu
1__global__ void gemm_scalar_coalesced(const float* A, const float* B, float* C, 2 int M, int N, int K) { 3 int row = blockIdx.y * blockDim.y + threadIdx.y; 4 int col = blockIdx.x * blockDim.x + threadIdx.x; 5 if (row >= M || col >= N) return; 6 7 float acc = 0.0f; 8 for (int k = 0; k < K; ++k) { 9 acc += A[row * K + k] * B[k * N + col]; 10 } 11 C[row * N + col] = acc; 12}

Now adjacent lanes read adjacent B[k * N + col] values and write adjacent outputs. Threads sharing a row request the same AAA address instead of 32 unrelated addresses. NVIDIA's current guidance describes coalescing as combining a warp's accesses into the necessary memory transactions and treats adjacent access as a high-priority pattern.[2]Reference 2CUDA C++ Best Practices Guidehttps://docs.nvidia.com/cuda/cuda-c-best-practices-guide/

MappingAdjacent lanes varyInner-loop B accessC storeMain waste
scalar stridedoutput rowsame column, distant A rowsstride NNNscattered transactions
scalar coalescedoutput columnadjacent columnsadjacent columnsrepeated global input loads

Coalescing repairs transaction shape. It doesn't preserve an input tile for reuse across multiple inner products. Shared memory handles that next.

Turn global loads into shared tiles

Pick a 16×1616 \times 1616×16 output tile. One block loads a 16×1616 \times 1616×16 tile of AAA and a 16×1616 \times 1616×16 tile of BBB into shared memory, computes 256 partial dot products, then advances 16 positions along KKK. Every loaded AAA value can feed 16 output columns, and every loaded BBB value can feed 16 output rows.

Diagram showing Global A and B, Shared 16 x 16 tiles, Thread or warp fragments, and Accumulate across K.
Global A and B, Shared 16 x 16 tiles, Thread or warp fragments, and Accumulate across K.

Two block-wide barriers form correctness boundaries. A first barrier prevents a thread from reading a shared value before another thread writes it. A second prevents a fast thread from overwriting a tile that a slower thread still needs. NVIDIA's GEMM example uses the same shared-memory idea to remove redundant global loads and requires block synchronization when warps consume values written by other warps.[2]Reference 2CUDA C++ Best Practices Guidehttps://docs.nvidia.com/cuda/cuda-c-best-practices-guide/

gemm_lab.cu
1constexpr int TILE = 16; 2 3__global__ void gemm_shared(const float* A, const float* B, float* C, 4 int M, int N, int K) { 5 __shared__ float As[TILE][TILE]; 6 __shared__ float Bs[TILE][TILE]; 7 8 int row = blockIdx.y * TILE + threadIdx.y; 9 int col = blockIdx.x * TILE + threadIdx.x; 10 float acc = 0.0f; 11 12 for (int k0 = 0; k0 < K; k0 += TILE) { 13 int ak = k0 + threadIdx.x; 14 int bk = k0 + threadIdx.y; 15 As[threadIdx.y][threadIdx.x] = 16 (row < M && ak < K) ? A[row * K + ak] : 0.0f; 17 Bs[threadIdx.y][threadIdx.x] = 18 (bk < K && col < N) ? B[bk * N + col] : 0.0f; 19 __syncthreads(); 20 21#pragma unroll 22 for (int k = 0; k < TILE; ++k) { 23 acc += As[threadIdx.y][k] * Bs[k][threadIdx.x]; 24 } 25 __syncthreads(); 26 } 27 28 if (row < M && col < N) C[row * N + col] = acc; 29}

Notice how edge handling preserves barrier participation. Each cooperative load checks its own A or B coordinate and writes zero only when that source coordinate is out of range. A thread without a valid output may still load an input needed by its neighbors, and it mustn't return before __syncthreads(). Only the final global store is predicated.

For a complete 16×16×1616 \times 16 \times 1616×16×16 step, the block performs 2⋅163=81922 \cdot 16^3 = 81922⋅163=8192 FLOPs. It loads two 256-element FP32 tiles, or 2048 bytes, before the output store. Ignoring the store until the full KKK loop ends, that step exposes 8192/2048=48192/2048=48192/2048=4 FLOP/B at the global-to-shared boundary. Larger threadblock tiles can raise reuse further, but they also consume more shared memory and registers.

An edge thread has row >= M. Why must it still reach both barriers inside the K loop?

Answer

Other threads in the block may need its B load and its barrier participation. The edge thread still performs both predicated loads, zero-fills only invalid source coordinates, reaches each block-wide barrier, and skips its final out-of-range store.

Let each thread keep more than one answer

The shared kernel assigns one accumulator register to each thread. It spends instruction and address overhead for every output while leaving each thread with little independent arithmetic to schedule. Register blocking gives a thread several nearby outputs.

Our next kernel keeps four accumulators per thread. A 16×1616 \times 1616×16 block computes a 16×6416 \times 6416×64 output tile. Each thread owns columns tx, tx + 16, tx + 32, and tx + 48 within the tile:

gemm_lab.cu
1float acc[4] = {0.0f, 0.0f, 0.0f, 0.0f}; 2 3#pragma unroll 4for (int k = 0; k < TILE; ++k) { 5 float a = As[threadIdx.y][k]; 6#pragma unroll 7 for (int j = 0; j < 4; ++j) { 8 acc[j] += a * Bs[k][threadIdx.x + j * TILE]; 9 } 10}

One shared AAA value now feeds four thread-local accumulators before the thread moves to the next kkk. The BBB tile is wider, so the block cooperatively loads it in several passes. The downloadable source includes those loads and guards its right edge.

More registers aren't free. A wider per-thread tile may increase instruction-level parallelism and reuse, but it can reduce occupancy or spill registers into local memory. CUTLASS's efficient GEMM description notes that accumulators can consume at least half of a thread's register budget in blocked kernels.[3]Reference 3Efficient GEMM in CUDAhttps://docs.nvidia.com/cutlass/latest/media/docs/cpp/efficient_gemm.html Check registers per thread, local-memory traffic, active warps, and runtime together. Occupancy alone isn't the objective.

Kernel layerReuse locationAccumulator ownerNew constraint
scalar coalescedhardware cachesone thread, one outputrepeated loads
shared 16×1616 \times 1616×16block shared memoryone thread, one outputbarriers and shared capacity
register 16×6416 \times 6416×64shared memory plus registersone thread, four outputsregister pressure
WMMAshared or global fragments plus registersone warp, 16×1616 \times 1616×16 tiledtype, layout, alignment, architecture

Register blocking changes ownership, not the mathematical product. Tensor Core MMA changes the instruction that performs each tile product.

Hand a tile to Tensor Cores

Tensor Cores are specialized matrix multiply-accumulate units. NVIDIA exposed Volta Tensor Cores through the Warp Matrix Multiply Accumulate (WMMA) API in CUDA 9. A full warp cooperates on a fragment whose element-to-lane mapping is intentionally opaque. The original FP16 path multiplies two 16×1616 \times 1616×16 fragments and accumulates into a 16×1616 \times 1616×16 fragment, commonly in FP32.[4]Reference 4Programming Tensor Cores in CUDA 9https://developer.nvidia.com/blog/programming-tensor-cores-cuda-9/

Three rules prevent subtle bugs:

  1. Every active lane in the warp must execute the WMMA operations coherently.
  2. Fragment layout and leading dimensions must match the backing memory.
  3. Edge fragments need padding or a separate predicated path because load_matrix_sync isn't a per-element guarded load.

For FP16 inputs, load_matrix_sync requires a 32-byte-aligned pointer and a leading dimension divisible by 8 elements. The FP32 output store requires the same pointer alignment and a leading dimension divisible by 4 elements. Padding every dimension to a multiple of 16 satisfies those stride rules, while cudaMalloc and the tile-aligned offsets preserve pointer alignment.[5]Reference 5CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/

The lab uses row-major FP16 inputs, FP32 accumulation, and one warp per 16×1616 \times 1616×16 output tile:

gemm_lab.cu
1__global__ void gemm_wmma_aligned(const half* A, const half* B, float* C, 2 int M, int N, int K) { 3#if __CUDA_ARCH__ >= 700 4 using namespace nvcuda; 5 int row = blockIdx.y * 16; 6 int col = blockIdx.x * 16; 7 8 wmma::fragment<wmma::accumulator, 16, 16, 16, float> c_frag; 9 wmma::fill_fragment(c_frag, 0.0f); 10 11 for (int k0 = 0; k0 < K; k0 += 16) { 12 wmma::fragment<wmma::matrix_a, 16, 16, 16, half, 13 wmma::row_major> a_frag; 14 wmma::fragment<wmma::matrix_b, 16, 16, 16, half, 15 wmma::row_major> b_frag; 16 wmma::load_matrix_sync(a_frag, A + row * K + k0, K); 17 wmma::load_matrix_sync(b_frag, B + k0 * N + col, N); 18 wmma::mma_sync(c_frag, a_frag, b_frag, c_frag); 19 } 20 wmma::store_matrix_sync(C + row * N + col, c_frag, N, 21 wmma::mem_row_major); 22#endif 23}

M isn't used inside the aligned kernel because the host pads all three dimensions to multiples of 16. The runtime launches only complete padded tiles.

A row-major GEMM with logical shape M 73, N 61, K 67 padded to 80 by 64 by 80. Five K panels of A and B feed one highlighted bottom-right 16 by 16 C tile through warp-level MMA. The output tile keeps 9 by 13 logical values and crops zero-padded rows and columns.
For logical shape $73 \times 61 \times 67$, the bottom-right output tile is incomplete. Padding to $80 \times 64 \times 80$ lets all five WMMA K steps load full fragments; zeros contribute nothing, and the host crops C back to $73 \times 61$.

Padding is explicit work. For the lab's default M=257M=257M=257, N=263N=263N=263, and K=251K=251K=251, WMMA sees 272×272×256272 \times 272 \times 256272×272×256. Logical work remains 2MNK2MNK2MNK, while padding raises executed work to 2MpNpKp2M_pN_pK_p2Mp​Np​Kp​. The lab prints both counts because a padded path can appear inefficient when its extra work stays hidden.

Layout is part of the type

A[row * K + k] describes row-major AAA. B[k * N + col] describes row-major BBB. Changing a fragment declaration to column-major without transforming data doesn't request the same matrix through a faster path. It requests different logical elements.

Libraries often name GEMMs with layout pairs such as NN, NT, or TN. Those letters describe whether operands are consumed in their stored or transposed orientation. Always record:

  • logical shapes [M,K][M,K][M,K], [K,N][K,N][K,N], and [M,N][M,N][M,N];
  • physical row or column order;
  • leading dimensions and batch strides;
  • alignment and any padded dimensions;
  • input, accumulator, and output dtypes.

An output that looks plausibly random can still be a correct multiplication of the wrong layout.

Correctness needs two references

The FP32 kernels multiply original FP32 inputs. Their CPU reference accumulates those same values in FP64. The WMMA kernel first rounds each input to FP16, so its reference converts the rounded FP16 values back to FP32 and accumulates them in FP64. Comparing WMMA against the unrounded input product would mix input quantization error with accumulation error.

The lab accepts an element when:

∣Cgpu−Cref∣≤atol+rtol ∣Cref∣|C_{\text{gpu}}-C_{\text{ref}}| \leq \text{atol} + \text{rtol}\,|C_{\text{ref}}|∣Cgpu​−Cref​∣≤atol+rtol∣Cref​∣

Absolute tolerance (atol) protects results near zero. Relative tolerance (rtol) scales with result magnitude. Neither pair is universal. Increase KKK, widen input range, introduce cancellation, change accumulator dtype, or add a fused epilogue, and the error distribution changes.

Use three correctness shapes before timing large squares:

ShapePurposeFailure it exposes
31×29×3731 \times 29 \times 3731×29×37small odd dimensionsbad edge predicates and padding
257×263×251257 \times 263 \times 251257×263×251several partial tileswrong grid math or crop
128×128×4097128 \times 128 \times 4097128×128×4097long reductionaccumulation error and K-tail handling

Seed input generation, keep values bounded, include zeros and signed values, and fail on NaN or Inf. Then run NVIDIA Compute Sanitizer to catch memory and synchronization defects that numerical comparison may miss.[6]Reference 6Compute Sanitizerhttps://docs.nvidia.com/compute-sanitizer/ComputeSanitizer/

terminal
1compute-sanitizer --tool memcheck ./gemm_lab 31 29 37 1 2compute-sanitizer --tool racecheck ./gemm_lab 31 29 37 1 3compute-sanitizer --tool synccheck ./gemm_lab 31 29 37 1

Run the lab and keep the receipt

Check the installed toolkit and device first. Compile for the GPU you will execute on, not for a favorite architecture copied from another machine:

terminal
1nvidia-smi --query-gpu=name,compute_cap --format=csv 2nvcc --version 3 4GPU_ARCH=${GPU_ARCH:-sm_80} 5nvcc -O3 -std=c++17 -lineinfo -arch="$GPU_ARCH" \ 6 assets/gemm_lab.cu -o gemm_lab 7./gemm_lab 257 263 251 20

The default build target is sm_80 only as an explicit starting choice. Use sm_70 for a Volta WMMA lab, sm_80 for Ampere, or the matching target for a newer device. A binary built only for sm_80 isn't a Hopper-specialized TMA kernel.

Exact latency and throughput are device-specific measurements, so the expected output fixes schema and correctness status without inventing performance values:

Expected output shape
1device=<GPU name> cc=<major.minor> shape=257x263x251 iterations=20 2scalar_strided PASS max_abs=<measured> max_rel=<measured> mean_ms=<measured> tflop_s=<measured> 3scalar_coalesced PASS max_abs=<measured> max_rel=<measured> mean_ms=<measured> tflop_s=<measured> 4shared_16x16 PASS max_abs=<measured> max_rel=<measured> mean_ms=<measured> tflop_s=<measured> 5register_16x64 PASS max_abs=<measured> max_rel=<measured> mean_ms=<measured> tflop_s=<measured> 6wmma_f16_f32 PASS max_abs=<measured> max_rel=<measured> mean_ms=<measured> tflop_s=<measured> 7wmma_padding=272x272x256 logical_flops=33930682 executed_flops=37879808

The program performs five warmups, records CUDA events around repeated launches in one stream, synchronizes the stop event, and reports mean kernel time. For a serious receipt, collect at least median and tail values over multiple process runs. Rotate buffers or state whether warm-cache behavior is intentional.

Use a structured row so someone else can reproduce the comparison:

gemm-receipt.json
1{ 2 "hardware": {"gpu": "record nvidia-smi name", "compute_capability": "record it"}, 3 "software": {"driver": "record it", "cuda": "record nvcc version", "git_commit": "record it"}, 4 "workload": {"M": 257, "N": 263, "K": 251, "warmups": 5, "iterations": 20}, 5 "path": {"kernel": "register_16x64", "input": "fp32", "accumulator": "fp32", "layout": "row-row-row"}, 6 "timing": {"method": "CUDA events in one stream", "cache_state": "warm"}, 7 "correctness": {"reference": "CPU fp64 on fp32 inputs", "atol": 0.0002, "rtol": 0.0002}, 8 "result": {"status": "fill after run", "mean_ms": "fill after run", "tflop_s": "fill after run"} 9}

Don't write 4.2x faster unless the receipt names both kernels, same shape, same device state, same timing method, same precision contract, and equivalent correctness. Compare against a tuned library such as cuBLAS or a CUTLASS profiler kernel before calling a handwritten kernel competitive.

Nsight Compute can then explain a timing change. Start with a small metric set instead of collecting every counter:

terminal
1ncu --set basic --kernel-name regex:gemm_register_blocked --launch-count 1 \ 2 ./gemm_lab 512 512 512 2

Record requested and actual kernel names, memory sectors, achieved occupancy, register count, shared-memory use, tensor-pipe activity, and replay warnings. NVIDIA's profiler documentation also provides roofline analysis for connecting measured arithmetic intensity to compute and bandwidth ceilings.[7]Reference 7Nsight Compute Profiling Guidehttps://docs.nvidia.com/nsight-compute/ProfilingGuide/

Where asynchronous copies begin

The shared and WMMA kernels issue a load, wait, compute, and repeat. A pipelined main loop instead prepares tile t+1t+1t+1 while computing tile ttt:

pipeline-shape.cu
1prime_async_copy(tile[0]); 2 3for (int t = 0; t < k_tiles; ++t) { 4 wait_until_ready(tile[t % stages]); 5 if (t + 1 < k_tiles) { 6 issue_async_copy(tile[(t + 1) % stages]); 7 } 8 mma(accumulator, tile[t % stages]); 9}

This snippet expresses ownership and overlap. It isn't a portable implementation: copy instructions, barriers, address spaces, alignment, stage count, and MMA schedule all depend on target architecture.

NVIDIA documents two distinct hardware boundaries:

PathFirst compute capabilityTransfer scaleTypical kernel responsibility
synchronous cooperative loadbroad CUDA supportthreads load scalar or vector valuesexplicit loads and block barriers
LDGSTS asynchronous global-to-shared copy8.0+small transferswarp or block pipeline with staged shared tiles
Tensor Memory Accelerator (TMA)9.0+bulk multidimensional transfersdescriptor, transaction barrier, staged consumer

LDGSTS support starts at compute capability 8.0. TMA starts at 9.0 and adds bulk multidimensional copies.[5]Reference 5CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/ Current CUDA documentation also warns that some high-level cuda::memcpy_async calls fall back to synchronous copies when alignment or size requirements aren't met, while lower-level TMA APIs make violating those requirements undefined behavior.[8]Reference 8Asynchronous Data Copieshttps://docs.nvidia.com/cuda/cuda-programming-guide/04-special-topics/async-copies.html

An async-copy kernel returns correct values but matches the synchronous kernel's timing. Why isn't the API name proof that transfer overlapped arithmetic?

Answer

A high-level copy can fall back to a synchronous path when its alignment or size contract isn't met. Even with an asynchronous instruction, too little independent computation, too few pipeline stages, or waits in the wrong place can remove overlap. Inspect the emitted instruction path and stall metrics before changing the stage count.

Hopper's warp-group MMA path adds another boundary. WGMMA is collective across four contiguous warps and targets sm_90a; it has its own fence, commit, and wait protocol.[9]Reference 9CUTLASS and CuTe Documentationhttps://docs.nvidia.com/cutlass/latest/overview.html Replacing wmma::mma_sync with an instruction name doesn't produce a correct Hopper pipeline.

CUTLASS shows why the chapter stops at this line. Its GEMM main loops double-buffer shared-memory tiles and warp fragments so memory movement overlaps arithmetic, then select threadblock schedules and epilogues around the target shape.[3]Reference 3Efficient GEMM in CUDAhttps://docs.nvidia.com/cutlass/latest/media/docs/cpp/efficient_gemm.html Use the CUTLASS profiler to obtain a verified library receipt rather than treating demonstration examples as benchmarks:

terminal
1./tools/profiler/cutlass_profiler \ 2 --operation=Gemm --m=2048 --n=2048 --k=2048 \ 3 --A=f16:row --B=f16:row --C=f32:row \ 4 --accum=f32 --verification-enabled=true

CUTLASS kernel availability and command arguments vary by built version and target architecture. Record the repository tag or commit, build configuration, kernel name, and profiler verification result. NVIDIA's current CUTLASS examples page explicitly says examples aren't performance benchmarks and directs measurement work to the profiler.[10]Reference 10CUTLASS Programming Exampleshttps://github.com/NVIDIA/cutlass/blob/main/examples/README.md

Diagnose the first bad signal

SymptomLikely causeFirst checkRepair
wrong values only on bottom or right edgemissing predicate or croprun 31×29×3731 \times 29 \times 3731×29×37zero-fill input tails and guard output stores
hang in shared kernelsome threads skipped a barrierrun synccheck on an edge shapekeep every block thread in tile barriers
transposed-looking outputphysical layout disagrees with fragment layoutcheck indices and leading dimensionsmake layout contract explicit or transform data
WMMA illegal accessunpadded dimensions or bad alignmentprint Mp,Np,KpM_p,N_p,K_pMp​,Np​,Kp​ and pointerspad, align, and launch complete fragments
WMMA passes small K but drifts at large Ktolerance ignores reduction lengthcompare rounded-input reference across K sweepset evidence-based tolerance or stronger accumulation
coalesced kernel isn't fastercache already hid loads, shape is too small, or timing is noisyinspect sectors and repeat distributionkeep evidence, don't assume mapping change must win
register kernel slows downspills or lower useful occupancyinspect registers and local-memory trafficreduce per-thread tile or retune block shape
no tensor-pipe activitybinary, dtype, layout, or path missed Tensor Coresrecord architecture and instruction metricscompile correct target and verify selected kernel
async version matches synchronous timingcopy didn't overlap or fell backinspect instruction path and stall reasonsmeet alignment contract and pipeline enough work

Every row starts from a symptom, not a favorite optimization. If correctness fails, stop timing. If timing moves without the expected counter change, revisit the hypothesis.

Keep the artifact honest

A complete lab submission contains:

  • source and exact compile command;
  • device, driver, toolkit, and target architecture;
  • odd, tiled, and long-KKK correctness shapes;
  • reference dtype plus atol and rtol;
  • warmup, timing, synchronization, and cache-state method;
  • logical and padded operation counts;
  • one profiler capture tied to a named kernel;
  • a cuBLAS or CUTLASS baseline with the same input and output contract;
  • conclusions limited to measured shapes.

The progression now has a stable meaning. Coalescing repairs warp transactions. Shared tiling captures reuse. Register blocking increases per-thread reuse and independent accumulation. WMMA moves a cooperative tile onto Tensor Cores. Async copies and TMA overlap movement with compute, but only after architecture-specific synchronization and layout contracts are correct.

Complete the lesson

Mastery Check

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

1.For row-major B, which scalar thread mapping makes a warp's B loads contiguous at fixed k?

Correct answer: threadIdx.x selects output column

CUDA linearizes threadIdx.x first. Mapping it to output columns makes adjacent lanes read adjacent B[k, col] values and write adjacent C values.

2.What does shared-memory tiling change in the GEMM main loop?

Correct answer: It lets a block reuse A and B values across several outputs

A block loads input tiles once from global memory, then many threads reuse those values while computing an output tile.

3.Why does a thread zero-fill an out-of-range shared-memory load instead of returning early?

Correct answer: It must still participate in block-wide barriers

Returning before __syncthreads() can leave other threads waiting. Zero filling preserves the product and keeps barrier participation uniform.

4.A register-blocked kernel gets slower while local-memory traffic appears. Which explanation fits both signals?

Correct answer: Its extra accumulators spilled out of registers

A wider per-thread tile raises register demand. Once values spill to local memory, added traffic can erase the intended reuse benefit.

5.Why does the WMMA path compare against a reference built from FP16-rounded inputs?

Correct answer: It separates input rounding from accumulation error

WMMA multiplies rounded FP16 inputs. A reference using those same rounded values isolates differences caused by accumulation and execution order.

6.For logical shape 257 by 263 by 251, why does the lab run WMMA on 272 by 272 by 256?

Correct answer: It pads each dimension to complete 16-element fragments

The teaching kernel loads full 16 by 16 by 16 WMMA fragments. Zero padding makes edge fragments valid, and the host crops the logical output.

7.Which receipt makes a claimed speedup interpretable?

Correct answer: Same shape, precision, correctness gate, hardware, and timing method

A speed comparison needs equivalent work and correctness plus enough environment and timing detail for another engineer to reproduce it.

8.Which architecture boundary matches NVIDIA's current asynchronous-copy documentation?

Correct answer: LDGSTS starts at CC 8.0 and TMA starts at CC 9.0

Ampere-class CC 8.0 adds the small global-to-shared asynchronous path, while Hopper-class CC 9.0 adds TMA bulk multidimensional transfers.

8 questions remaining.

Next Step
Continue to Low-Precision GPU Programming

GEMM exposed how input, accumulator, and output dtypes belong to one correctness and performance contract; next you'll program those lower-precision formats, scaling rules, and error checks directly.

PreviousGPU Compilers and Kernel Languages
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

Roofline: An Insightful Visual Performance Model for Multicore Architectures

Williams, S., Waterman, A., & Patterson, D. · 2009

https://doi.org/10.1145/1498765.1498785

CUDA C++ Best Practices Guide

NVIDIA · 2026 · Official documentation

https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/

Efficient GEMM in CUDA

NVIDIA CUTLASS Team · 2026 · NVIDIA CUTLASS Documentation

https://docs.nvidia.com/cutlass/latest/media/docs/cpp/efficient_gemm.html

Programming Tensor Cores in CUDA 9

Appleyard, J., & Yokim, S. · 2017 · NVIDIA Technical Blog

https://developer.nvidia.com/blog/programming-tensor-cores-cuda-9/

CUDA Programming Guide.

NVIDIA · 2026

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

Compute Sanitizer

NVIDIA · 2026 · Official documentation

https://docs.nvidia.com/compute-sanitizer/ComputeSanitizer/

Nsight Compute Profiling Guide

NVIDIA · 2026 · Official documentation

https://docs.nvidia.com/nsight-compute/ProfilingGuide/

Asynchronous Data Copies

NVIDIA · 2026 · CUDA Programming Guide

https://docs.nvidia.com/cuda/cuda-programming-guide/04-special-topics/async-copies.html

CUTLASS and CuTe Documentation

NVIDIA · 2026 · Official documentation

https://docs.nvidia.com/cutlass/latest/overview.html

CUTLASS Programming Examples

NVIDIA CUTLASS Team · 2026 · NVIDIA CUTLASS Repository

https://github.com/NVIDIA/cutlass/blob/main/examples/README.md

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.