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 196 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/31
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 Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel 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 & AutoscalingGPU Failure OperationsDistributed 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.

32 min read
Learning path
Step 149 of 196 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 97% of a GPU. When you write the textbook three-loop dot product in naive CUDA, each thread spends its entire existence waiting on High Bandwidth Memory (HBM). Swapping thread coordinates fixes memory coalescing, packing scattered bytes into wide bus transactions. Tiling inputs into shared memory raises arithmetic intensity by orders of magnitude, amortizing DRAM fetches across hundreds of threads. 2D register blocking then insulates shared memory itself, feeding multiple accumulators per thread from register files. Finally, Tensor Cores replace scalar arithmetic instructions with warp-synchronous matrix multiply-accumulate operations, shifting compute ceilings into hundreds of TFLOP/s.

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 build one operation, C=ABC = ABC=AB, through five successive kernel stages. This represents the α=1\alpha=1α=1, β=0\beta=0β=0 case of general matrix-matrix multiplication (GEMM), C←αAB+βCC \leftarrow \alpha AB + \beta CC←αAB+βC, where we don't read the old output buffer. 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 memory 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.

You'll trace warp addresses, eliminate shared-memory bank conflicts with padding and swizzling, explain both shared-memory barriers, check a padded WMMA layout, and produce a correctness-gated measurement receipt. The host C++ checks and CPU index model run on any machine without a GPU. Compiling CUDA, executing kernels, running sanitizers, and collecting hardware timings remain required on your target device.

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.

In naive scalar code, each thread computes a single output Ci,j=∑k=0K−1Ai,kBk,jC_{i,j} = \sum_{k=0}^{K-1} A_{i,k} B_{k,j}Ci,j​=∑k=0K−1​Ai,k​Bk,j​. For each step along kkk, the thread fetches one float from AAA (4 bytes) and one float from BBB (4 bytes), performing one fused multiply-add (2 FLOPs). That yields an arithmetic intensity of:

Inaive=2 FLOPs4 bytes+4 bytes=0.25 FLOP/BI_{\text{naive}} = \frac{2\ \text{FLOPs}}{4\ \text{bytes} + 4\ \text{bytes}} = 0.25\ \text{FLOP/B}Inaive​=4 bytes+4 bytes2 FLOPs​=0.25 FLOP/B

Consider an NVIDIA A100 GPU with 2,039 GB/s HBM2e memory bandwidth and 19.5 TFLOP/s peak FP32 CUDA core throughput. At 0.25 FLOP/B, attainable performance caps out at:

Throughputnaive=2,039 GB/s×0.25 FLOP/B≈509.8 GFLOP/s≈0.51 TFLOP/s\text{Throughput}_{\text{naive}} = 2{,}039\ \text{GB/s} \times 0.25\ \text{FLOP/B} \approx 509.8\ \text{GFLOP/s} \approx 0.51\ \text{TFLOP/s}Throughputnaive​=2,039 GB/s×0.25 FLOP/B≈509.8 GFLOP/s≈0.51 TFLOP/s

That's just 2.6% of the GPU's 19.5 TFLOP/s compute capability. Over 97% of the arithmetic hardware sits idle, starved for bytes.

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

When n=4096n=4096n=4096, Iideal≈682.7I_{\text{ideal}} \approx 682.7Iideal​≈682.7 FLOP/B. Large square GEMMs contain abundant reuse to become compute-bound, but only if the kernel captures that reuse on chip. 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 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 across warp lanes 0 through 31. In this strided kernel with a 16×1616 \times 1616×16 block, lanes 0 through 15 own rows 0 through 15 of column 0, while lanes 16 through 31 own rows 0 through 15 of column 1. Because AAA is stored row-major, row r+1r+1r+1 begins KKK elements (4K4K4K bytes) after row rrr.

When K=64K=64K=64 floats (256 bytes), lane 0 requests byte 0, lane 1 requests byte 256, and lane 2 requests byte 512. A hardware DRAM cache sector is 32 bytes wide (holding 8 contiguous FP32 floats). Because adjacent lanes touch addresses 256 bytes apart, each lane's request lands in a completely separate 32-byte sector. Lanes 0 through 15 force 16 distinct sector transactions for a single instruction. Stores to CCC suffer the exact same NNN-stride penalty. The numbers are mathematically correct, but memory hardware chokes on scattered transactions.

Swap index assignments 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}

With the coalesced kernel's 32×832 \times 832×8 block, a full warp of 32 lanes owns one row and 32 consecutive columns. For matrix AAA, every thread in the warp reads A[row * K + k]. All 32 lanes request the exact same address, which the SM services with a single 32-byte sector broadcast. For matrix BBB, lanes 0 through 31 request 32 consecutive columns (32×4=12832 \times 4 = 12832×4=128 bytes), fitting neatly into 4 contiguous 32-byte sectors. Stores to CCC similarly coalesce into 4 contiguous sectors.[2]Reference 2CUDA C++ Best Practices Guidehttps://docs.nvidia.com/cuda/cuda-c-best-practices-guide/

MappingAdjacent lanes varyInner-loop A accessInner-loop B accessC storeFirst warp sectors (k=0)
scalar stridedrow within each 16-lane halfstride KKK (16 sectors)two columns per warp (1 sector)stride NNN (16 sectors)33 sectors
scalar coalescedoutput columnshared row (1 broadcast sector)contiguous 32 floats (4 sectors)contiguous 32 floats (4 sectors)9 sectors

Coalescing cuts bus transactions by 73% for the first warp iteration. Yet its arithmetic intensity remains stuck at 0.25 FLOP/B. Packing memory requests tightly prevents bus transaction replays, but threads still reload inputs from global memory on every step. Shared memory tackles data reuse next.

For one fully active warp at k zero, strided versus coalesced mappings request 16 versus 1 distinct A sectors, 1 versus 4 B sectors, and 16 versus 4 C sectors. All bars use the same zero-based scale. These are address counts, not measured cache misses.
The coalesced mapping reduces bus transaction count from 33 sectors to 9 sectors for the first warp at $k=0$. While B reads expand from 1 to 4 sectors to service 32 columns, A and C shrink from 16 sectors to 1 and 4.

Turn global loads into shared tiles

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

Diagram showing Load / zero-fill tile, Block barrier: RAW ready, Read tile and accumulate, and Block barrier: WAR release.
Load / zero-fill tile, Block barrier: RAW ready, Read tile and accumulate, and Block barrier: WAR release.

Two block-wide barriers form correctness boundaries:

  1. Read-After-Write (RAW) barrier: Prevents any thread from reading a shared tile before all threads finish loading it from global memory.
  2. Write-After-Read (WAR) barrier: Prevents fast threads from looping around and overwriting shared memory before slower threads finish consuming the current tile.

NVIDIA's classic GEMM pattern relies on this cooperative staging to slash global DRAM transactions.[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}

Edge handling preserves barrier participation. Each cooperative load checks its own coordinate and writes zero when out of range. A thread outside MMM or NNN still loads an input needed by its valid neighbors and must participate in __syncthreads(). Early exits before barriers cause undefined behavior and GPU hangs. Only the final global store to CCC is predicated.

For each 16×16×1616 \times 16 \times 1616×16×16 tile step, the block performs 2⋅163=8,1922 \cdot 16^3 = 8{,}1922⋅163=8,192 FLOPs. It loads two 256-element FP32 tiles (2⋅256⋅4=2,0482 \cdot 256 \cdot 4 = 2{,}0482⋅256⋅4=2,048 bytes) from global memory. Arithmetic intensity at the global-to-shared boundary jumps to:

Ishared=8,192 FLOPs2,048 bytes=4.0 FLOP/BI_{\text{shared}} = \frac{8{,}192\ \text{FLOPs}}{2{,}048\ \text{bytes}} = 4.0\ \text{FLOP/B}Ishared​=2,048 bytes8,192 FLOPs​=4.0 FLOP/B

This represents a 16×16\times16× reduction in global memory traffic compared to scalar code. On our A100, 4.0 FLOP/B raises the bandwidth ceiling from 0.51 TFLOP/s to 2,039×4.0≈8.162{,}039 \times 4.0 \approx 8.162,039×4.0≈8.16 TFLOP/s.

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.

Eliminate shared-memory bank conflicts with padding and swizzling

Shared memory isn't an unconstrained cache. On modern NVIDIA architectures, shared memory is divided into 32 independent memory banks, interleaved cyclically in 4-byte (32-bit) words:

bank=(byte address4) mod 32\text{bank} = \left(\frac{\text{byte address}}{4}\right) \bmod 32bank=(4byte address​)mod32

Each bank can service only one 32-bit word per clock cycle. When multiple threads in a warp access different words within the same bank simultaneously, the hardware serializes the requests. A 16-way or 32-way bank conflict stalls the execution pipeline for 16 or 32 consecutive clock cycles.

Examine the inner loop access pattern in gemm_shared:

cpp
1acc += As[threadIdx.y][k] * Bs[k][threadIdx.x];

For BBB, adjacent threads in a warp have adjacent threadIdx.x values. At fixed kkk, lane jjj reads column jjj, accessing bank (k⋅16+j) mod 32(k \cdot 16 + j) \bmod 32(k⋅16+j)mod32. Across all 32 lanes, requests distribute across banks without conflicts.

For AAA, thread (ty,tx)(ty, tx)(ty,tx) reads As[threadIdx.y][k]. Notice that threads with different tytyty share the same column kkk. In a 32×3232 \times 3232×32 tile where stride is 32 floats:

bank=(row⋅32+k) mod 32=k mod 32\text{bank} = (\text{row} \cdot 32 + k) \bmod 32 = k \bmod 32bank=(row⋅32+k)mod32=kmod32

Every thread in the warp hits the exact same bank k mod 32k \bmod 32kmod32 with different row addresses. That triggers a catastrophic 32-way bank conflict, destroying shared-memory throughput.

Two techniques eliminate this serialization:

1. Stride padding

Add a padding float to the shared memory array declaration:

cpp
1constexpr int PAD = 1; 2__shared__ float As[TILE][TILE + PAD]; // Stride becomes 33 floats

With stride 33, the bank calculation becomes:

bank=(row⋅33+k) mod 32=(row+k) mod 32\text{bank} = (\text{row} \cdot 33 + k) \bmod 32 = (\text{row} + k) \bmod 32bank=(row⋅33+k)mod32=(row+k)mod32

Because gcd⁡(33,32)=1\gcd(33, 32) = 1gcd(33,32)=1, each consecutive row shifts the bank assignment by 1. All 32 threads land on 32 distinct banks. Bank conflicts drop to zero.

2. Bitwise XOR swizzling

While padding is simple, it wastes shared-memory capacity and breaks the 16-byte alignment required for 128-bit vector loads (float4 or uint4). Production kernels in libraries like CUTLASS use bitwise XOR swizzling instead.[3]Reference 3Efficient GEMM in CUDAhttps://docs.nvidia.com/cutlass/latest/media/docs/cpp/efficient_gemm.html

Swizzling permutes the column index by XORing it with bits from the row index:

cpp
1int swizzled_col = col ^ ((row / 4) % 8);

By XORing row bits into column bits, each row's columns map to a distinct permutation of banks. No memory is wasted on padding, 128-bit memory alignment is preserved, and bank conflicts vanish.

TechniqueMemory overhead128-bit vector alignmentConflict elimination
None (natural 2D array)0%PreservedSevere 16-way or 32-way serialization
Stride padding (+1 float)∼3%\sim 3\%∼3% capacity wasteBroken (non-power-of-two stride)Completely eliminated
Bitwise XOR swizzling0% capacity wastePreserved (powers of two aligned)Completely eliminated

Let each thread keep more than one answer

In gemm_shared, each thread computes a single output value. For every multiply-accumulate step, the thread reads one float from As and one float from Bs: 8 bytes read from shared memory for 2 FLOPs. That gives an internal shared-memory arithmetic intensity of just 0.25 FLOP/B. The SM's shared-memory load pipelines and register ports become saturated.

Register blocking gives each thread a 2D tile of RM×RNR_M \times R_NRM​×RN​ outputs held in local registers. In our lab kernel, each thread computes 4 horizontal outputs (1×41 \times 41×4 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}

A single float loaded from As into register a now feeds 4 independent multiply-adds. In an 8×88 \times 88×8 register tile (RM=8,RN=8R_M = 8, R_N = 8RM​=8,RN​=8), a thread loads 8 values from AAA and 8 values from BBB (16 floats = 64 bytes), then executes 8×8=648 \times 8 = 648×8=64 FMAs (128 FLOPs):

Ismem=2⋅RM⋅RN4(RM+RN)=128 FLOPs64 bytes=2.0 FLOP/BI_{\text{smem}} = \frac{2 \cdot R_M \cdot R_N}{4(R_M + R_N)} = \frac{128\ \text{FLOPs}}{64\ \text{bytes}} = 2.0\ \text{FLOP/B}Ismem​=4(RM​+RN​)2⋅RM​⋅RN​​=64 bytes128 FLOPs​=2.0 FLOP/B

That represents an 8×8\times8× reduction in shared-memory load traffic. Registers insulate shared memory just as shared memory insulates global DRAM.

More registers aren't free. Each thread must allocate RM×RNR_M \times R_NRM​×RN​ accumulator registers plus temporary storage for inputs. An SM holds 65,536 32-bit registers. If each thread consumes 64 registers, an SM can host at most 65,536/64=1,02465{,}536 / 64 = 1{,}02465,536/64=1,024 active threads (capping occupancy at 50% on Ampere). If register allocations spill into local memory (backed by DRAM), high latencies and cache thrashing erase all benefits.[3]Reference 3Efficient GEMM in CUDAhttps://docs.nvidia.com/cutlass/latest/media/docs/cpp/efficient_gemm.html Tuning register tile sizes balances arithmetic reuse against warp occupancy.

Kernel layerPrimary reuse locationAccumulator ownerAdded constraint
scalar coalescedHardware L1/L2 cachesOne thread, one outputRepeated global loads
shared 16×1616 \times 1616×16Threadblock shared memoryOne thread, one outputBarriers, shared capacity, bank conflicts
register 16×6416 \times 6416×64Shared memory plus registersOne thread, four outputsRegister pressure and occupancy limits
Tensor Core WMMAFragment registers / MMA pipesOne warp, 16×1616 \times 1616×16 tilePrecision, alignment, and fragment layout

Hand a tile to Tensor Cores

Tensor Cores are specialized execution units designed for matrix multiply-accumulate operations. NVIDIA introduced Volta Tensor Cores through the Warp Matrix Multiply Accumulate (WMMA) API in CUDA 9. Rather than having individual threads execute scalar FMAs, all 32 lanes in a warp cooperate to multiply fragments of matrices:

D=A⋅B+CD = A \cdot B + CD=A⋅B+C

For FP16 inputs, a standard fragment represents a 16×16×1616 \times 16 \times 1616×16×16 matrix tile, accumulating into FP32 accumulators.[4]Reference 4Programming Tensor Cores in CUDA 9https://developer.nvidia.com/blog/programming-tensor-cores-cuda-9/

Three rules prevent bugs when programming WMMA:

  1. Full warp cooperation: All 32 lanes must participate with matching matrix shapes and types. A diverged or partially active warp produces undefined results.
  2. Strict pointer and stride alignment: For FP16 inputs, load_matrix_sync requires a 32-byte-aligned base pointer and a leading dimension divisible by 8 elements (16 bytes). The FP32 output store requires 32-byte pointer alignment and a leading dimension divisible by 4 elements (16 bytes).
  3. Padded fragment boundaries: Tensor Core instructions can't load partial fragments. Edges must be padded to multiples of 16 in all three dimensions (Mp,Np,KpM_p, N_p, K_pMp​,Np​,Kp​), zero-filled, and cropped after computation.[5]Reference 5CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/

The lab kernel uses row-major FP16 inputs, FP32 accumulation, and one warp per 16×1616 \times 1616×16 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 defined(__CUDA_ARCH__) && __CUDA_ARCH__ < 700 4 asm("trap;"); // Never silently execute an empty unsupported kernel. 5#else 6 using namespace nvcuda; 7 int row = blockIdx.y * 16; 8 int col = blockIdx.x * 16; 9 10 wmma::fragment<wmma::accumulator, 16, 16, 16, float> c_frag; 11 wmma::fill_fragment(c_frag, 0.0f); 12 13 for (int k0 = 0; k0 < K; k0 += 16) { 14 wmma::fragment<wmma::matrix_a, 16, 16, 16, half, 15 wmma::row_major> a_frag; 16 wmma::fragment<wmma::matrix_b, 16, 16, 16, half, 17 wmma::row_major> b_frag; 18 wmma::load_matrix_sync(a_frag, A + row * K + k0, K); 19 wmma::load_matrix_sync(b_frag, B + k0 * N + col, N); 20 wmma::mma_sync(c_frag, a_frag, b_frag, c_frag); 21 } 22 wmma::store_matrix_sync(C + row * N + col, c_frag, N, 23 wmma::mem_row_major); 24#endif 25}

Because host setup pads all dimensions to multiples of 16 (Mp,Np,KpM_p, N_p, K_pMp​,Np​,Kp​), the grid launches only complete tiles.

A five by five grid highlights the last tile row of A. B highlights its last tile column, and C highlights their intersection. Each grid cell is a 16 by 16 tile; five K panels contribute to the output edge tile, of which only 9 by 13 values are retained.
For logical shape $73 \times 61 \times 67$, padding to $80 \times 64 \times 80$ allows five complete WMMA K-steps. Edge tile $(4,3)$ accumulates 9 valid rows and 13 valid columns, while zero-padded values are cropped during output retrieval.

Padding adds explicit arithmetic overhead. For default shapes M=257,N=263,K=251M=257, N=263, K=251M=257,N=263,K=251, WMMA executes on 272×272×256272 \times 272 \times 256272×272×256:

Overhead=2⋅272⋅272⋅2562⋅257⋅263⋅251−1=37,879,80833,930,682−1≈11.6%\text{Overhead} = \frac{2 \cdot 272 \cdot 272 \cdot 256}{2 \cdot 257 \cdot 263 \cdot 251} - 1 = \frac{37{,}879{,}808}{33{,}930{,}682} - 1 \approx 11.6\%Overhead=2⋅257⋅263⋅2512⋅272⋅272⋅256​−1=33,930,68237,879,808​−1≈11.6%

Reported TFLOP/s must always use logical operations (2MNK2MNK2MNK), measuring useful work completed rather than padded instruction waste.

Layout is part of the type

A[row * K + k] assumes row-major storage. B[k * N + col] assumes row-major storage. Changing a fragment layout tag from wmma::row_major to wmma::col_major without physically transposing memory doesn't create a faster kernel; it computes a completely wrong matrix product.

Always specify and verify:

  • Logical shapes [M,K][M,K][M,K], [K,N][K,N][K,N], [M,N][M,N][M,N];
  • Storage order (row-major or column-major);
  • Leading dimensions and memory strides;
  • Memory alignment and padding multiples;
  • Input, accumulator, and output data types.

Why mixed precision demands FP32 accumulation

Tensor Cores achieve extreme throughput by multiplying reduced-precision inputs (FP16 or BF16) and accumulating sums in full FP32. Understanding why FP16 accumulation fails reveals the numerical foundation of deep learning systems.

Consider the IEEE 754 half-precision (FP16) format:

  • 1 sign bit, 5 exponent bits, 10 mantissa bits;
  • Machine epsilon ϵFP16=2−10≈9.77×10−4\epsilon_{\text{FP16}} = 2^{-10} \approx 9.77 \times 10^{-4}ϵFP16​=2−10≈9.77×10−4;
  • Dynamic range: minimum subnormal ≈6×10−8\approx 6 \times 10^{-8}≈6×10−8, maximum finite value ≈65,504\approx 65{,}504≈65,504.

In a transformer model with hidden dimension K=4096K = 4096K=4096, calculating a single dot product sums 4,096 products. If inputs are normalized with variance ∼1\sim 1∼1, partial sums quickly grow toward 10210^2102 or 10310^3103.

When an FP16 accumulator reaches 1,0241{,}0241,024 (2102^{10}210), its least significant mantissa bit represents 1,024×2−10=1.01{,}024 \times 2^{-10} = 1.01,024×2−10=1.0. Any subsequent product smaller than 0.5 completely disappears when added (swamping)! Over a 4,096-step reduction, hundreds of small gradients or activations round to zero, causing severe gradient underflow and network divergence.

In contrast, single precision (FP32) provides:

  • 8 exponent bits, 23 mantissa bits;
  • Machine epsilon ϵFP32=2−23≈1.19×10−7\epsilon_{\text{FP32}} = 2^{-23} \approx 1.19 \times 10^{-7}ϵFP32​=2−23≈1.19×10−7;
  • Dynamic range up to 3.4×10383.4 \times 10^{38}3.4×1038.

Even when the running sum reaches 1,0241{,}0241,024, the FP32 resolution step is 1,024×2−23≈1.22×10−41{,}024 \times 2^{-23} \approx 1.22 \times 10^{-4}1,024×2−23≈1.22×10−4, easily preserving tiny numerical updates across long reductions.

Diagram showing Matrix A (FP16: 10-bit mantissa), Tensor Core Multiplier (16-bit), Matrix B (FP16: 10-bit mantissa), and Full-Precision Accumulator (FP32: 23-bit mantissa).
Matrix A (FP16: 10-bit mantissa), Tensor Core Multiplier (16-bit), Matrix B (FP16: 10-bit mantissa), and Full-Precision Accumulator (FP32: 23-bit mantissa).

The two-reference testing protocol

Validating a mixed-precision kernel requires two distinct CPU references:

  1. Arithmetic correctness reference: Convert FP32 inputs to FP16, convert them back to FP32, and accumulate in FP64. This reference isolates kernel arithmetic and accumulation fidelity from input quantization loss.
  2. Application fidelity reference: Accumulate original unrounded FP32 inputs in FP64. This measures total end-to-end numerical change introduced by quantizing inputs to FP16.

We check kernel correctness against Reference 1 using absolute and relative error bounds:

∣Cgpu−Cref∣≤atol+rtol⋅∣Cref∣|C_{\text{gpu}} - C_{\text{ref}}| \le \text{atol} + \text{rtol} \cdot |C_{\text{ref}}|∣Cgpu​−Cref​∣≤atol+rtol⋅∣Cref​∣

For FP16 WMMA with FP32 accumulation, the lab enforces atol=5×10−3\text{atol} = 5 \times 10^{-3}atol=5×10−3 and rtol=2×10−3\text{rtol} = 2 \times 10^{-3}rtol=2×10−3. Absolute tolerance handles values near zero, while relative tolerance scales with large outputs.

The hardware roofline across kernel stages

We can now place every kernel stage onto the hardware roofline to understand how memory hierarchy optimizations shift the performance ceiling.

On an NVIDIA A100 SXM4 GPU:

  • HBM2e bandwidth: Bmem=2,039B_{\text{mem}} = 2{,}039Bmem​=2,039 GB/s.
  • Peak FP32 CUDA core throughput: PFP32=19.5P_{\text{FP32}} = 19.5PFP32​=19.5 TFLOP/s.
  • Peak FP16 Tensor Core throughput: PTC=312P_{\text{TC}} = 312PTC​=312 TFLOP/s.

The roofline knee defines the minimum arithmetic intensity required to hit peak compute:

KneeFP32=19,500 GFLOP/s2,039 GB/s≈9.56 FLOP/B\text{Knee}_{\text{FP32}} = \frac{19{,}500\ \text{GFLOP/s}}{2{,}039\ \text{GB/s}} \approx 9.56\ \text{FLOP/B}KneeFP32​=2,039 GB/s19,500 GFLOP/s​≈9.56 FLOP/B KneeTC=312,000 GFLOP/s2,039 GB/s≈153.0 FLOP/B\text{Knee}_{\text{TC}} = \frac{312{,}000\ \text{GFLOP/s}}{2{,}039\ \text{GB/s}} \approx 153.0\ \text{FLOP/B}KneeTC​=2,039 GB/s312,000 GFLOP/s​≈153.0 FLOP/B
Kernel StageArithmetic Intensity (III)A100 CeilingHardware Bottleneck
Naive FP320.25 FLOP/B0.51 TFLOP/sExtreme HBM starvation (2.6% of FP32 peak)
Coalesced FP320.25 FLOP/B0.51 TFLOP/sBus coalesced, but identical HBM traffic
Shared Tiling (16×1616 \times 1616×16)4.0 FLOP/B8.16 TFLOP/sHBM bound; SMEM bandwidth and barrier overhead
Shared Tiling (32×3232 \times 3232×32)8.0 FLOP/B16.3 TFLOP/sApproaching FP32 CUDA core ceiling
2D Register Blocked (128×128128 \times 128128×128)32.0 FLOP/B19.5 TFLOP/sFP32 Compute Bound! Saturates CUDA cores
Naive WMMA (16×1616 \times 1616×16)4.0 FLOP/B8.16 TFLOP/sSeverely Memory Bound! (2.6% of 312 TFLOP/s)
Tuned Production GEMM (CUTLASS)>160>160>160 FLOP/B312 TFLOP/sTensor Core Bound! Multi-stage async pipeline

Notice the critical insight: writing a naive WMMA kernel without large shared-memory tiles and multi-stage pipelining leaves Tensor Cores 97% starved for data! Reaching 312 TFLOP/s requires clearing the 153 FLOP/B roofline knee, which demands large threadblock tiles (128×256128 \times 256128×256), deep register blocking, and asynchronous prefetching.

Run the lab and keep the receipt

Save the download as assets/gemm_lab.cu relative to your working directory. Check the installed toolkit and device first. Set GPU_ARCH to an architecture supported by both the device and compiler, such as sm_80 on an Ampere GPU:

terminal
1nvidia-smi --query-gpu=name,compute_cap --format=csv 2nvcc --version 3nvcc --list-gpu-code 4 5: "${GPU_ARCH:?Set GPU_ARCH to your device's supported target, such as sm_80}" 6nvcc -O3 -std=c++17 -lineinfo -arch="$GPU_ARCH" \ 7 assets/gemm_lab.cu -o gemm_lab 8./gemm_lab 257 263 251 20

WMMA hardware support began at compute capability 7.0, but toolkit support is a separate constraint. CUDA 13.0 removed offline compilation for architectures below 7.5; a Volta sm_70 exercise needs a compatible CUDA 12.x toolchain. A binary targeting sm_80 doesn't become a Hopper-specialized TMA kernel merely by running on newer hardware.[6]Reference 6CUDA Toolkit 13.0 Release Noteshttps://docs.nvidia.com/cuda/archive/13.0.0/cuda-toolkit-release-notes/index.html

The CLI rejects nonpositive or malformed dimensions, padded buffers exceeding signed 32-bit index ranges, and unsupported grid sizes. Those checks don't guarantee enough device memory. The host FP64 reference also costs O(MNK)O(MNK)O(MNK): start with small correctness shapes before attempting large matrices.

Exact latency and throughput require device measurements. This is the output schema when every kernel passes:

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

After correctness, the program performs five warmups, records CUDA events around repeated launches in one stream, synchronizes the stop event, and reports the mean event interval per launch. It excludes allocation, transfers, FP16 conversion, padding, reference computation, and cropping. Short kernels can include stream idle gaps between host launches. Reusing the same buffers also favors warm-cache behavior. For a serious receipt, repeat whole runs and report the distribution of their means.

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.

The lab's FP16 WMMA row and FP32 scalar rows have different input precision. Their timing ratio alone isn't a same-precision optimization result. Use rounded inputs and an explicitly matched library compute mode for a controlled comparison.

Run Compute Sanitizer to verify memory and race safety:

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

Nsight Compute can then explain timing changes. Start with a small metric set:

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

This command captures the first matching launch. The basic set is a starting capture. Add sections for memory sectors, occupancy, registers, shared memory, and tensor-pipe activity as needed.[7]Reference 7Nsight Compute Profiling Guidehttps://docs.nvidia.com/nsight-compute/ProfilingGuide/

Check the index model without a GPU

Download gemm_index_check.py alongside the CUDA file. It executes the two scalar ownership maps, cooperative shared loads, four-output register layout, and zero-padding/crop algebra on six integer fixtures, including 17×65×1917 \times 65 \times 1917×65×19 and 73×61×6773 \times 61 \times 6773×61×67. It assumes both block barriers work; it can't detect real scheduling races, floating-point error, or WMMA instruction behavior.

gemm-index-check.py
1import runpy 2 3runpy.run_path("assets/gemm_index_check.py", run_name="__main__")
Output
1PASS 6 shapes: scalar ownership, shared/register indices, padding/crop 2first-warp sectors (A, B, C): strided=(16, 1, 16), coalesced=(1, 4, 4) 3CPU integer fixtures only; no CUDA, floating-point, barrier, or speed validation.

The CUDA download also exposes a host-only build that checks its actual argument parser, index-overflow guards, reference multiplication, NaN rejection, and tolerance comparison. It excludes every CUDA declaration:

terminal
1clang++ -x c++ -std=c++17 -Wall -Wextra -Werror -DGEMM_HOST_CHECK \ 2 assets/gemm_lab.cu -o gemm_host_check 3./gemm_host_check

Where asynchronous copies and TMA take over

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:

Two-stage ownership sketch (not CUDA code)
1acquire_empty(stage[0]) 2issue_copy(input_tile=0, destination=stage[0]) 3 4for t in 0 .. k_tiles-1: 5 current = stage[t % 2] 6 wait_copy_complete_for_all_consumers(current) 7 if t + 1 < k_tiles: 8 next = stage[(t + 1) % 2] 9 acquire_empty(next) 10 issue_copy(input_tile=t+1, destination=next) 11 accumulate(accumulator, current) 12 wait_all_consumers_finished(current) 13 release_empty(current)

The two slots must not alias. Copy completion makes a tile readable; consumer completion makes its storage reusable. An asynchronous MMA can still read shared memory after issue, so releasing a stage at instruction issue is unsafe.

NVIDIA documents three distinct hardware boundaries:

Hardware MechanismFirst ArchitectureData Movement PathKernel Programming Model
Synchronous cooperative loadPre-AmpereGMEM →\to→ Regs →\to→ SMEMExplicit thread loads and __syncthreads()
cp.async (LDGSTS)Ampere (CC 8.0)GMEM →\to→ SMEM directlyBypasses register file; async pipeline tokens
Tensor Memory Accelerator (TMA)Hopper (CC 9.0)Multi-D GMEM →\to→ SMEMHardware descriptor; transaction barriers

In pre-Ampere code, moving data from global DRAM into shared memory required two instructions per 16 bytes: LDG (load from global to register) and STS (store from register to shared memory). This consumed register file bandwidth and SM issue slots just to route bytes into shared memory.

Ampere introduced cp.async (hardware instruction LDGSTS), copying data directly from global memory into shared memory without touching registers. This frees registers for accumulators and lets data movement proceed in the background while math units execute compute instructions.

Hopper introduces two further leaps:

  1. Warpgroup MMA (wgmma): Four warps (128 threads) execute matrix multiply-accumulate cooperatively. Unlike Volta and Ampere WMMA, which required loading input matrices from shared memory into thread registers (ldmatrix), wgmma reads matrix inputs directly from shared memory. This cuts register allocation in half.
  2. Tensor Memory Accelerator (TMA): A dedicated hardware unit that copies multidimensional tensor tiles between global memory and shared memory asynchronously. A single thread issues a TMA descriptor instruction; hardware handles address calculations, strides, and out-of-bounds boundary clipping automatically.[5]Reference 5CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/

CUTLASS 3.x and its CuTe template engine wrap these hardware primitives into composable C++ layouts (Tensor, Layout, TiledCopy, TiledMMA), automating multi-stage pipelining and warp specialization.[8]Reference 8CUTLASS and CuTe Documentationhttps://docs.nvidia.com/cutlass/latest/overview.html Use the CUTLASS profiler for verified library receipts:

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 --alpha=1 --beta=0 --verification-enabled=true

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.

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 memory accessUnpadded dimensions or bad alignmentPrint Mp,Np,KpM_p,N_p,K_pMp​,Np​,Kp​ and pointersPad, align, and launch complete fragments
Shared kernel throughput collapses32-way shared memory bank conflictCheck row stride divisibility by 32Add +1 stride padding or apply XOR swizzling
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 or shape is too smallInspect 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 has a stable meaning: coalescing repairs warp transactions; shared tiling captures DRAM reuse; XOR swizzling eliminates bank conflicts; register blocking increases per-thread reuse and independent accumulation; WMMA moves cooperative tiles onto Tensor Cores; and asynchronous copies with TMA overlap data movement with compute.

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 across warp lanes. Mapping threadIdx.x to output columns makes adjacent lanes read adjacent B[k, col] values and write contiguous C values.

2.What is the global memory arithmetic intensity of a naive scalar FP32 GEMM kernel?

Correct answer: 0.25 FLOP/B

For each inner-loop step, a thread loads 4 bytes of A and 4 bytes of B (8 bytes) to perform one multiply-add (2 FLOPs). 2 FLOPs / 8 bytes = 0.25 FLOP/B, leaving modern GPUs heavily memory-bound.

3.Why does a 2D shared memory tile As[32][32] cause severe bank conflicts when threads read columns?

Correct answer: All 32 threads in a warp access different rows at the same column, and (row * 32 + k) mod 32 maps every thread to bank k

Because the row stride is 32 floats, every row offset is a multiple of 32. Adding column k leaves the bank index (row * 32 + k) % 32 = k % 32 identical for all 32 lanes, causing a 32-way conflict.

4.Why is bitwise XOR swizzling preferred over stride padding in production Tensor Core kernels?

Correct answer: It eliminates bank conflicts without wasting shared memory capacity or breaking 128-bit vector alignment

Padding introduces non-power-of-two strides that break 16-byte alignment needed by vectorized copy instructions like LDGSTS. XOR swizzling permutes bank addresses mathematically without padding bytes.

5.Why must FP16 Tensor Core GEMMs accumulate products in FP32 rather than FP16?

Correct answer: FP16 has only 10 mantissa bits, causing partial sums above 1024 to round away smaller additions and swamp the reduction

When an FP16 running sum reaches 1024, its resolution step is 1.0. Any partial product under 0.5 completely disappears when added, causing severe underflow and loss of precision.

6.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 synchronization barriers

Returning early before __syncthreads() desynchronizes the threadblock and hangs the GPU. Zero-filling ensures out-of-range threads participate in barriers safely.

7.On an NVIDIA A100 GPU (2039 GB/s HBM, 312 TFLOP/s FP16 Tensor Core), what is the roofline knee for Tensor Cores?

Correct answer: Approximately 153 FLOP/B

Dividing peak Tensor Core compute (312,000 GFLOP/s) by memory bandwidth (2,039 GB/s) gives ~153 FLOP/B. Kernels with lower intensity remain memory-bandwidth bound.

8.What major capability distinguishes Hopper Warpgroup MMA (wgmma) from earlier Volta/Ampere WMMA?

Correct answer: Four warps cooperate to issue MMA instructions that read matrix inputs directly from shared memory without register staging

Hopper wgmma coordinates 128 threads and reads operands A and B directly out of shared memory, bypassing thread register files and drastically cutting register pressure.

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/

CUDA Toolkit 13.0 Release Notes

NVIDIA · 2025

https://docs.nvidia.com/cuda/archive/13.0.0/cuda-toolkit-release-notes/index.html

Nsight Compute Profiling Guide

NVIDIA · 2026 · Official documentation

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

CUTLASS and CuTe Documentation

NVIDIA · 2026 · Official documentation

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

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.