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 ScaleGPU Kernel Performance Engineering Lab
🚀HardInference Optimization

GPU Kernel Performance Engineering Lab

Optimize one CUDA matrix-transpose kernel through a repeatable evidence loop. Diagnose coalescing, shared-memory bank conflicts, occupancy limits, correctness failures, and the point where a reduction or scan is the right primitive.

29 min read
Learning path
Step 146 of 196 in the full curriculum
Correct GPU Profiling and BenchmarkingGPU Parallel Primitives Lab

Personalize this lesson

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

You change four lines of CUDA, recompile, and watch the kernel runtime drop by 40%. It feels like an instant win until your verification pass flags thousands of stale NaN values across the output matrix. The optimization didn't win anything; it just computed garbage at breakneck speed.

Real GPU performance engineering isn't about guessing clever tricks or chasing microbenchmarks in isolation. It's a disciplined, evidence-driven loop: form a concrete hypothesis about hardware bottlenecks, implement a single causal code modification, prove bit-exact correctness against edge-case matrices, and verify that Nsight Compute counters move for the exact mechanical reason you predicted.

In this lab, you'll optimize an out-of-place single-precision (N×NN \times NN×N) matrix transpose kernel. Transpose is the canonical proving ground for mastering GPU memory hierarchy because it performs zero arithmetic beyond indexing: exactly one read and one write per element. Every nanosecond spent in the kernel reflects global memory bus transactions, L1/L2 cache line utilization, shared memory bank conflicts, and instruction-level latency hiding.

Three prerequisites supply the vocabulary. CUDA for ML Training established grids, blocks, warps, asynchronous launches, and memory hierarchy. Accelerator Architecture Field Guide connected arithmetic intensity and resource ceilings to workload bounds. GPU Profiling, Correctness, and Benchmarking separated host timelines, kernel counters, and correctness evidence. Here those ideas control one kernel from first measurement through recovery.

Keep one experiment stable

The workload transposes an N×NN \times NN×N row-major float32 matrix into a separate output allocation. This is an out-of-place transpose; passing overlapping input and output would violate the contract. If A[r,c]A[r,c]A[r,c] lives at input[r * N + c], the output satisfies:

B[c,r]=A[r,c]B[c,r] = A[r,c]B[c,r]=A[r,c]

No floating-point arithmetic changes a value. The checker compares each float's 32-bit representation via bitwise equality, not a tolerance or checksum. It also reports maximum absolute error as a diagnostic; the mismatch count decides correctness. Positive and negative zero differ in bits even though their absolute difference is zero.

The harness limits N to 4096. In IEEE 754 single-precision float32, the significand has 24 bits of precision (23 stored bits plus 1 implicit leading bit). Integers from 0 through 224=16,777,2162^{24} = 16{,}777{,}216224=16,777,216 have exact, unique representations. For N=4096N=4096N=4096, the matrix contains exactly 40962=16,777,2164096^2 = 16{,}777{,}21640962=16,777,216 elements, meaning every single element receives a unique float value that avoids rounding ambiguity. Before each standalone validation launch, the harness fills the output with NaN bit patterns (0xff). A skipped store fails immediately instead of inheriting a correct value from a previous variant.

The supplied harness contains five variants:

VariantPurposeIncluded in normal benchmark?
copyreads and writes every element without transposing, giving a same-harness memory-movement referenceyes
naivereads adjacent input words but writes the transpose with stride NNNyes
tiledstages a 32×3232 \times 3232×32 tile in shared memory so global reads and writes are adjacentyes
paddedchanges shared tile to 32×3332 \times 3332×33 so column reads spread across banksyes
brokenremoves required block barrier to create a shared-memory raceno, run deliberately

copy isn't a transpose baseline. It performs a different operation with the same logical read and write volume. Treat it as context for the memory path, not as a denominator for a claimed transpose speedup.

Write the hypothesis before editing, reject incorrect candidates, then compare repeated timings under identical conditions. A surprising counter should change the next experiment, not become a story invented after the result.

Build the lab on an NVIDIA GPU

You need an NVIDIA GPU, a CUDA Toolkit with nvcc and Compute Sanitizer, and Nsight Compute's ncu command. Tool versions and GPU identity belong beside every result because compiler decisions, architecture, clocks, and profiler metric availability can change evidence.

Save the CUDA source and its host-checking header in the same directory. Save the recovery template for the deliberate failure later. The shell commands below target Bash on a CUDA-equipped machine. A CPU or Apple GPU can't execute these CUDA kernels; the later Python exercise checks indexing only.

Create an artifact directory and capture the environment. Keep pipefail enabled so a compiler or CUDA failure isn't hidden by a successful tee:

terminal
1mkdir -p artifacts/run-01 2set -euo pipefail 3nvidia-smi -L | tee artifacts/run-01/gpu.txt 4nvidia-smi | tee artifacts/run-01/nvidia-smi.txt 5nvcc --version | tee artifacts/run-01/nvcc.txt 6ncu --version | tee artifacts/run-01/ncu.txt 7compute-sanitizer --version | tee artifacts/run-01/compute-sanitizer.txt 8sha256sum transpose_lab.cu transpose_checks.hpp | tee artifacts/run-01/source.sha256

Compile optimized C++17 code with line information for attribution. -Xptxas=-v reports kernel resource use, including registers per thread, static shared memory per block, and local memory spills. -arch=native targets the GPU visible during compilation; this binary isn't a portable multi-architecture artifact. For a separate build host, select an explicit architecture supported by that Toolkit and the target GPU.[1]Reference 1NVIDIA CUDA Compiler Driver NVCChttps://docs.nvidia.com/cuda/cuda-compiler-driver-nvcc/

terminal
1nvcc -std=c++17 -O3 -arch=native -lineinfo -Xptxas=-v \ 2 -o transpose_lab transpose_lab.cu \ 3 2>&1 | tee artifacts/run-01/build.txt

Inspect the build log rather than guessing resource consumption:

  • the compiler names each instantiated kernel;
  • transpose_tiled<0> reserves 32×32×4=409632 \times 32 \times 4 = 409632×32×4=4096 bytes of static shared memory;
  • transpose_tiled<1> reserves 32×33×4=422432 \times 33 \times 4 = 422432×33×4=4224 bytes;
  • register count is recorded directly from ptxas;
  • verify that spill stores and spill loads are both zero;
  • no compilation error is present.

First exercise boundary shapes, including tiles that aren't full. Each command must exit zero:

boundary-cases.sh
1for n in 1 7 31 32 33 65 257; do 2 ./transpose_lab all "$n" 1 > "artifacts/run-01/check-${n}.csv" 3done

Then run the fixed 4096×40964096 \times 40964096×4096 performance case for 100 measured launches per normal variant:

terminal
1./transpose_lab all 4096 100 \ 2 | tee artifacts/run-01/benchmark.csv

The harness prints CSV columns variant, correct, mismatches, max_abs_error, average_ms, and effective_gb_s. Device and launch shape go to standard error. A fresh-buffer check runs before warmup and another after timing. Failure suppresses performance fields and returns exit code 2. The known-racy broken variant always returns 2 and is never timed, even if its sampled output matches. Runtime or argument errors return 1.

Pass conditions for the four normal variants are:

FieldRequired evidence
correcttrue for copy, naive, tiled, and padded
mismatches0
max_abs_error0
average_mspositive average CUDA-event interval after five warmup launches
effective_gb_spositive logical read-plus-write rate, interpreted only with profiler evidence

Don't rank variants yet. A single process run can be affected by clocks, device temperature, competing work, or startup state. Repeat separate processes under the same conditions, alternate order by round, and preserve every row:

terminal
1{ 2 echo 'variant,correct,mismatches,max_abs_error,average_ms,effective_gb_s' 3 for round in 1 2 3 4 5; do 4 if (( round % 2 == 1 )); then 5 order=(naive tiled padded) 6 else 7 order=(padded tiled naive) 8 fi 9 for variant in "${order[@]}"; do 10 ./transpose_lab "$variant" 4096 100 2>> artifacts/run-01/repeated-launches.txt | tail -n 1 11 done 12 done 13} | tee artifacts/run-01/repeated-benchmark.csv

These are repeated-buffer, warm-cache measurements, not streaming cold-DRAM measurements. The event interval excludes allocation, host transfers, and validation, but includes any device idle gaps between host-submitted launches. For tiny matrices, launch overhead can dominate. Profiler replay and cache-control settings can produce different cache conditions, so don't equate its counters with the unprofiled timing run automatically.

Compare repeated samples, not only the best row. Overlapping samples don't by themselves prove equality; report the variation and collect enough evidence to resolve a distinct difference. Don't claim a win from one favorable sample.

Read the naive memory path

CUDA coalesces a warp's global-memory requests into cache-line transactions that cover requested addresses. Modern NVIDIA architectures route global loads and stores through the L1/Texture cache and L2 cache down to DRAM channels. Hardware organizes memory traffic into 128-byte cache lines, which are subdivided into four 32-byte sectors.[2]Reference 2CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/ When threads in a warp access consecutive words within an aligned 128-byte segment, the hardware services all 32 threads with a single 128-byte cache request.

The naive kernel reads input with ideal spatial order, then scatters that order on output. Here is the indexing logic from transpose_naive:

transpose-naive.cu
1__global__ void transpose_naive(float* output, const float* input, int n) { 2 const int x = blockIdx.x * kTile + threadIdx.x; 3 const int y = blockIdx.y * kTile + threadIdx.y; 4 5 for (int offset = 0; offset < kTile; offset += kBlockRows) { 6 if (x < n && y + offset < n) { 7 const size_t input_index = static_cast<size_t>(y + offset) * n + x; 8 const size_t output_index = static_cast<size_t>(x) * n + y + offset; 9 output[output_index] = input[input_index]; 10 } 11 } 12}

Look closely at what happens across a single warp (32 threads where threadIdx.y is constant and threadIdx.x runs from 0 through 31):

  1. Input read: input_index = (y + offset) * n + x. Because x = blockIdx.x * 32 + threadIdx.x, consecutive threads access consecutive float32 values in row y + offset. The 32 threads request 32×4=12832 \times 4 = 12832×4=128 contiguous bytes. The memory subsystem serves all 32 lanes with a single 128-byte transaction (four adjacent 32-byte sectors). Coalescing efficiency is 100%.
  2. Output store: output_index = x * n + (y + offset). Here x multiplies the stride NNN. Lane 0 writes to row 0, Lane 1 writes to row 1, Lane 2 writes to row 2, and Lane 31 writes to row 31. For N=4096N=4096N=4096, consecutive lanes write to addresses spaced by 4096×4=16,3844096 \times 4 = 16{,}3844096×4=16,384 bytes (16 KB16\text{ KB}16 KB).
  3. The transaction explosion: Because each thread's 4-byte float lands in a completely different 32-byte sector and 128-byte cache line, the memory controller can't coalesce them. It issues 32 separate 32-byte sector transactions to write just 128 bytes of payload.

That means transferring 1024 bytes across the memory bus to commit 128 bytes of data, wasting 87.5% of requested transfer volume. The write pipeline chokes on memory controller queues, and kernel throughput collapses.

Transpose does almost no useful floating-point arithmetic. Its logical data volume per launch is one read and one write for every element:

logical bytes=2N2⋅sizeof⁡(float)\text{logical bytes} = 2N^2 \cdot \operatorname{sizeof}(\text{float})logical bytes=2N2⋅sizeof(float)

For N=4096N=4096N=4096, that's 134,217,728134{,}217{,}728134,217,728 bytes, or 128 MiB. The harness reports:

effective GB/s=2N2⋅4average time in seconds⋅109\text{effective GB/s} = \frac{2N^2 \cdot 4}{\text{average time in seconds} \cdot 10^9}effective GB/s=average time in seconds⋅1092N2⋅4​

Effective bandwidth isn't physical DRAM traffic. Cache hits, excess transactions, error-correction traffic, and architecture-specific paths make hardware traffic differ. Use it as a workload-normalized rate for comparing the same operation on the same setup.

A 4096×40964096 \times 40964096×4096 transpose averages 0.2500.2500.250 ms. What effective rate should the ledger record, and what hardware claim remains unsupported?

Answer

The logical volume is 2×40962×4=134,217,7282 \times 4096^2 \times 4 = 134{,}217{,}7282×40962×4=134,217,728 bytes. Dividing by 0.0002500.0002500.000250 seconds and then by 10910^9109 gives about 536.9536.9536.9 GB/s. That is a workload-normalized effective rate. It doesn't establish physical DRAM traffic or percentage of peak bandwidth without profiler counters and the matching hardware conditions.

Profile before editing

Nsight Compute groups kernel evidence into named sections. LaunchStats reports grid, block, registers, and shared memory. SpeedOfLight compares compute and memory resource throughput with device ceilings. MemoryWorkloadAnalysis traces traffic through the memory hierarchy. Occupancy reports active-warps capacity and its resource limiters.[3]Reference 3Nsight Compute Profiling Guidehttps://docs.nvidia.com/nsight-compute/ProfilingGuide/

Collect the first measured naive launch, skipping its one validation launch and five warmups. The full section set can require replay, so use the unprofiled harness for timing. CLI filters and launch selection are documented separately from the profiling metrics.[4]Reference 4Nsight Compute CLIhttps://docs.nvidia.com/nsight-compute/NsightComputeCli/

terminal
1ncu --set full \ 2 --kernel-name-base function \ 3 --kernel-name transpose_naive \ 4 --launch-skip 6 \ 5 --launch-count 1 \ 6 --force-overwrite \ 7 -o artifacts/run-01/naive \ 8 ./transpose_lab naive 4096 1 9 10ncu --import artifacts/run-01/naive.ncu-rep --page details \ 11 > artifacts/run-01/naive.txt

Read the report in this order:

  1. Confirm kernel name, matrix size, block 32 x 8, and one profiled launch.
  2. Check whether memory throughput is closer to its ceiling than compute throughput (dram__throughput.avg.pct_of_peak_sustained_elapsed).
  3. Inspect global load and store sector efficiency (smsp__sass_average_data_bytes_per_sector_mem_global_op_st.pct). In transpose_naive, store efficiency plunges near 12.5% because only 4 bytes are utilized per 32-byte sector.
  4. Check static shared memory is zero for the naive variant.
  5. Read the occupancy limiter, but don't optimize it unless evidence connects the limiter to latency or throughput.

Low arithmetic intensity makes the memory path the primary suspect, not a confirmed bottleneck. Small launches can instead be dominated by overhead, and even large kernels can hit memory-instruction throughput before DRAM bandwidth. Roofline is a ceiling model, not a promise that every low-intensity kernel reaches peak bandwidth.[5]Reference 5Roofline: An Insightful Visual Performance Model for Multicore Architectureshttps://doi.org/10.1145/1498765.1498785 Use the profiler to identify which resource limits this launch.

Write one bound statement before editing:

artifacts/run-01/naive-bound.txt
1Workload: float32 transpose, N=4096, block=32x8 2Correctness: exact transpose, 0 mismatches 3Observed limiter: global memory store sector inefficiency (32 separate sector transactions per warp) 4Source evidence: SpeedOfLight memory bound; MemoryWorkloadAnalysis store sector efficiency ~12.5% 5Hypothesis: make output stores adjacent by staging a tile in shared memory

If the report doesn't support a memory-path limit, revise the hypothesis before crediting a shared-memory rewrite with a speedup.

Coalesce both global directions

Shared memory is on-chip SRAM allocated per Streaming Multiprocessor (SM). It provides low latency and terabytes per second of aggregate bandwidth. The tiled transpose loads an input tile into shared memory with adjacent global reads. After a block barrier, threads read the tile with swapped indexes and write adjacent output words. Official CUDA guidance uses shared memory for this reordering pattern.[6]Reference 6CUDA C++ Best Practices Guidehttps://docs.nvidia.com/cuda/cuda-c-best-practices-guide/

The core kernel below accepts Padding as a compile-time tile-width change. Variant tiled instantiates Padding = 0:

transpose-tiled.cu
1template <int Padding> 2__global__ void transpose_tiled(float* output, const float* input, int n) { 3 __shared__ float tile[kTile][kTile + Padding]; 4 5 const int input_x = blockIdx.x * kTile + threadIdx.x; 6 const int input_y = blockIdx.y * kTile + threadIdx.y; 7 8 #pragma unroll 9 for (int offset = 0; offset < kTile; offset += kBlockRows) { 10 if (input_x < n && input_y + offset < n) { 11 tile[threadIdx.y + offset][threadIdx.x] = 12 input[static_cast<size_t>(input_y + offset) * n + input_x]; 13 } 14 } 15 16 __syncthreads(); 17 18 const int output_x = blockIdx.y * kTile + threadIdx.x; 19 const int output_y = blockIdx.x * kTile + threadIdx.y; 20 21 #pragma unroll 22 for (int offset = 0; offset < kTile; offset += kBlockRows) { 23 if (output_x < n && output_y + offset < n) { 24 output[static_cast<size_t>(output_y + offset) * n + output_x] = 25 tile[threadIdx.x][threadIdx.y + offset]; 26 } 27 } 28}

Notice the symmetry in coordinates:

  • During input loading, thread threadIdx.x reads global index input_y * n + input_x and writes to tile[threadIdx.y + offset][threadIdx.x]. Because input_x advances with threadIdx.x, global reads are 100% coalesced.
  • __syncthreads() guarantees all 256 threads in the thread block finish writing to tile before any thread reads from it.
  • During output storing, thread threadIdx.x writes to global index output_y * n + output_x. Because output_x = blockIdx.y * kTile + threadIdx.x, output_x advances with threadIdx.x! The global store is now 100% coalesced into a single 128-byte cache transaction.

Keep the barrier outside bounds checks so every thread in the block reaches it, including threads processing edge tiles.

Check the edge-tile mapping without a GPU

For N=33, most threads in the last tile are out of bounds. Does an admitted output read still have a producer? Its shared coordinate is (threadIdx.x, threadIdx.y + offset). The corresponding input coordinate is (blockIdx.y * 32 + threadIdx.x, blockIdx.x * 32 + threadIdx.y + offset), exactly the transpose of the admitted output coordinate. The two bounds conditions agree.

This CPU model enumerates those coordinates, requires every shared read to have a producer, and checks that every output location is written exactly once. It also checks the bank arithmetic. It doesn't execute CUDA, model warp scheduling, or measure a memory transaction.

transpose-index-model.py
1from collections import Counter 2 3def check_mapping(n): 4 output = {} 5 for by in range((n + 31) // 32): 6 for bx in range((n + 31) // 32): 7 tile = {} 8 for ty in range(8): 9 for tx in range(32): 10 for offset in range(0, 32, 8): 11 row, col = by * 32 + ty + offset, bx * 32 + tx 12 if row < n and col < n: 13 tile[ty + offset, tx] = (row, col) 14 # All producer writes precede consumer reads in this model. 15 for ty in range(8): 16 for tx in range(32): 17 for offset in range(0, 32, 8): 18 row, col = bx * 32 + ty + offset, by * 32 + tx 19 if row < n and col < n: 20 assert (row, col) not in output 21 output[row, col] = tile[tx, ty + offset] 22 assert len(output) == n * n 23 assert all(value == (col, row) for (row, col), value in output.items()) 24 25for n in (1, 7, 31, 32, 33, 65, 257): 26 check_mapping(n) 27print("seven square shapes: index coverage and transpose mapping pass") 28for stride in (32, 33, 34): 29 banks = Counter((lane * stride) % 32 for lane in range(32)) 30 print(f"stride {stride}: {len(banks)} banks, {max(banks.values())} words per bank") 31assert 2 * 4096**2 * 4 == 134_217_728 32print(f"example effective GB/s: {134_217_728 / 0.000250 / 1e9:.1f}")
Output
1seven square shapes: index coverage and transpose mapping pass 2stride 32: 1 banks, 32 words per bank 3stride 33: 32 banks, 1 words per bank 4stride 34: 16 banks, 2 words per bank 5example effective GB/s: 536.9

Run correctness and event timing first, then collect the profiler report:

terminal
1./transpose_lab tiled 4096 100 \ 2 | tee artifacts/run-01/tiled-benchmark.csv 3 4ncu --set full \ 5 --kernel-name-base function \ 6 --kernel-name 'regex:transpose_tiled' \ 7 --launch-skip 6 \ 8 --launch-count 1 \ 9 --force-overwrite \ 10 -o artifacts/run-01/tiled \ 11 ./transpose_lab tiled 4096 1

Expected evidence shows two distinct shifts:

  • global reads and writes now use adjacent lane addresses, curing global store serialization;
  • the shared-memory column read triggers a severe bank conflict.

Shared memory is split into 32 hardware banks, each 4 bytes wide (32 bits). Successive 32-bit words map cyclically to successive banks: bank⁡(k)=k mod 32\operatorname{bank}(k) = k \bmod 32bank(k)=kmod32. When threads in a warp access distinct banks, requests complete in parallel in a single clock cycle. If multiple threads request different words from the same bank, the hardware serializes access across multiple cycles.

In the unpadded tile tile[32][32], row rrr, column ccc sits at 1D float offset 32r+c32r + c32r+c. When reading down column ccc, thread lane iii reads row r=ir = ir=i:

bank⁡(i)=(32i+c) mod 32=c\operatorname{bank}(i) = (32i + c) \bmod 32 = cbank(i)=(32i+c)mod32=c

Every single lane 0,1,2,…,310, 1, 2, \dots, 310,1,2,…,31 requests a distinct word from Bank ccc. The hardware serializes the request into a 32-way bank conflict, taking 32 cycles instead of 1 cycle. The strided access penalty moved from global DRAM into on-chip shared memory.

Comparison of naive transpose, tiled transpose with a 32 by 32 tile, and padded transpose with a 32 by 33 tile. Naive transpose suffers from strided global writes with 32 separate transactions. Tiled transpose coalesces global writes but suffers 32-way shared memory bank conflicts on column reads. Padded transpose eliminates bank conflicts entirely by offsetting row strides, achieving 1-cycle parallel shared access and coalesced global memory transactions.
Memory access paths across three kernel variants. In naive transpose, strided column stores trigger 32 separate bus transactions per warp. Tiling with shared memory coalesces both global loads and stores, but unpadded column reads trigger a 32-way bank conflict. Adding one padding float shifts row strides modulo 32, granting conflict-free parallel access.

Query the installed Nsight Compute metrics to inspect shared memory conflicts:

terminal
1ncu --query-metrics | rg 'bank_conflicts|shared.*conflict' \ 2 | tee artifacts/run-01/bank-metrics.txt

In the detailed Nsight report for transpose_tiled<0>, look for l1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_ld.sum. The counter will report substantial shared load bank conflicts attributed directly to the line reading tile[threadIdx.x][threadIdx.y + offset].

Change the shared-memory stride

The next candidate changes only the tile declaration width:

padded-shared-tile.cu
1__shared__ float tile[32][33];

By adding a single padding float to each row, the row stride increases from 32 to 33. The column element for lane iii at column ccc now sits at word offset 33i+c33i + c33i+c:

bank⁡(i)=(33i+c) mod 32=(32i+i+c) mod 32=(i+c) mod 32\operatorname{bank}(i) = (33i + c) \bmod 32 = (32i + i + c) \bmod 32 = (i + c) \bmod 32bank(i)=(33i+c)mod32=(32i+i+c)mod32=(i+c)mod32

Because iii ranges from 0 through 31, (i+c) mod 32(i + c) \bmod 32(i+c)mod32 generates a complete cyclic permutation of the integers 0 through 31. Lane 0 accesses Bank ccc, Lane 1 accesses Bank (c+1) mod 32(c+1) \bmod 32(c+1)mod32, and Lane 31 accesses Bank (c+31) mod 32(c+31) \bmod 32(c+31)mod32.

Every lane hits a unique bank. The 32-way bank conflict vanishes completely, allowing all 32 lanes to complete their shared memory read in a single clock cycle. Padding doesn't alter matrix shape, output buffers, or logical global traffic. It changes address stride inside on-chip SRAM.[6]Reference 6CUDA C++ Best Practices Guidehttps://docs.nvidia.com/cuda/cuda-c-best-practices-guide/

Would float tile[32][34] remove the column conflict as completely as float tile[32][33]? Derive the bank pattern before answering.

Answer

No. A 34-word row gives bank⁡(i)=(34i+c) mod 32=(2i+c) mod 32\operatorname{bank}(i) = (34i + c) \bmod 32 = (2i + c) \bmod 32bank(i)=(34i+c)mod32=(2i+c)mod32. Because gcd⁡(34,32)=2\gcd(34, 32) = 2gcd(34,32)=2, lanes 0 through 15 reach 16 distinct banks, then lanes 16 through 31 repeat those exact same banks. Each bank receives two different words, leaving a two-way conflict. A 33-word row works because gcd⁡(33,32)=1\gcd(33, 32) = 1gcd(33,32)=1, so its stride is one modulo 32.

Check the padded result before timing or profiling it:

terminal
1./transpose_lab padded 4096 100 \ 2 | tee artifacts/run-01/padded-benchmark.csv 3 4ncu --set full \ 5 --kernel-name-base function \ 6 --kernel-name 'regex:transpose_tiled' \ 7 --launch-skip 6 \ 8 --launch-count 1 \ 9 --force-overwrite \ 10 -o artifacts/run-01/padded \ 11 ./transpose_lab padded 4096 1

Compare identical evidence fields across tiled and padded:

Evidencetiled expectationpadded expectationDecision use
exact transpose0 mismatches0 mismatchesreject either candidate that fails
logical global bytessame 2N2⋅42N^2 \cdot 42N2⋅4same 2N2⋅42N^2 \cdot 42N2⋅4keeps rate comparable
global access orderadjacent reads and writesadjacent reads and writesconfirms padding didn't regress coalescing
shared bank conflictscolumn conflict presentconflict removed for shown mappingtests causal hypothesis
static shared memory4096 bytes4224 bytesfeeds resource-limit check
event timemeasured distributionmeasured distributionkeep change only if repeat evidence improves

Don't write that padding is faster without citing your hardware, software, shape, precision, timing method, baseline, and correctness record. Architecture and toolchain decide the size of the benefit, so a universal speedup claim would be unfounded.

A candidate is 6% faster, but its edit both pads the tile and changes the block from 32 x 8 to 32 x 16. What can the evidence ledger conclude, and how should the experiment recover?

Answer

The run shows that the combined candidate differs, but it can't attribute the change to padding or launch geometry. Restore 32 x 8 and measure padding alone against the fixed baseline. Then hold tile width fixed and test 32 x 16 separately. Each branch still needs the same exact-output, profiler, sanitizer, and repeated-timing gates.

Loop unrolling and instruction-level parallelism

Hardware needs work in flight to hide memory latency. On modern GPUs, fetching a line from off-chip DRAM takes between 200 and 800 clock cycles. There are two primary mechanisms to tolerate that latency: warp-level parallelism (having enough active warps on the SM to switch between) and instruction-level parallelism (ILP, having multiple independent instructions in flight within the same thread).

In transpose_tiled, each block contains 32×8=25632 \times 8 = 25632×8=256 threads, but the tile holds 32×32=102432 \times 32 = 102432×32=1024 floats. Each thread processes four rows separated by kBlockRows = 8:

cpp
1#pragma unroll 2for (int offset = 0; offset < kTile; offset += kBlockRows) { 3 if (input_x < n && input_y + offset < n) { 4 tile[threadIdx.y + offset][threadIdx.x] = 5 input[static_cast<size_t>(input_y + offset) * n + input_x]; 6 } 7}

The #pragma unroll directive instructs the compiler to unroll all four iterations into straight-line SASS instructions. This transformation delivers two distinct benefits:

  1. Eliminating loop overhead: It removes induction variable increments, compare instructions, and branch jumps.
  2. Exposing independent loads to the scheduler: The compiler issues four independent global load instructions (LDG.E) back-to-back before any dependent write to shared memory. The memory pipeline dispatches these four memory requests concurrently, overlapping their latency windows.

Unrolling increases register pressure. Each in-flight memory operation requires dedicated registers to hold target addresses and loaded values. Inspect the compiler resource output from nvcc -Xptxas=-v:

terminal
1ptxas info : Compiling entry function '_Z15transpose_tiledILi1EEvPfPKfi' for 'sm_89' 2ptxas info : Used 20 registers, 4224 bytes smem, 0 bytes spill stores, 0 bytes spill loads

If register usage climbs too high (for example, above 32 or 48 registers per thread depending on architecture), the GPU can't fit as many concurrent warps on the SM. If registers exceed the allocation limit, the compiler spills variables to local memory (spill stores and spill loads). Local memory resides in off-chip DRAM, introducing cache thrashing and destroying throughput. Always verify that spill stores and loads remain zero.

Branch divergence is minimal in this kernel. For interior tiles where x<nx < nx<n and y+32≤ny + 32 \le ny+32≤n, every thread in the warp evaluates the bounds check to true. On boundary tiles (N=33N=33N=33 or N=65N=65N=65), threads beyond matrix limits take the false branch. Modern architectures with Independent Thread Scheduling execute divergent branches sequentially using thread masks, so keeping work outside bounds guards ensures maximum SIMT lane utilization.

Occupancy is a constraint, not a score

Occupancy is the ratio of active warps on a Streaming Multiprocessor (SM) to the hardware maximum. More resident warps can hide latency, but highest occupancy doesn't automatically deliver highest performance. Registers, shared memory, threads per block, and barriers can limit resident blocks, while forcing resource use down can introduce spills or extra instructions.[6]Reference 6CUDA C++ Best Practices Guidehttps://docs.nvidia.com/cuda/cuda-c-best-practices-guide/[3]Reference 3Nsight Compute Profiling Guidehttps://docs.nvidia.com/nsight-compute/ProfilingGuide/

The lab block uses 32 x 8 = 256 threads, or eight 32-thread warps. The concrete resource comparison across variants shows:

ResourcenaivetiledpaddedWhat to inspect
threads per block256256256LaunchStats
static shared memory per block0 B4096 B4224 Bcompiler output and LaunchStats
added shared memory from padding0 B0 B128 Barithmetic plus report
registers per threadcompiler-specificcompiler-specificcompiler-specific-Xptxas=-v and LaunchStats
resident blocks or warpsdevice-specificdevice-specificdevice-specificOccupancy limiter

The 128-byte increase from padding may leave occupancy unchanged or cross an allocation boundary for a particular resource configuration. Read the report. Don't infer resident blocks from source alone.

Use these diagnoses when evaluating profiler evidence:

Profiler evidenceLikely constraintNext experiment
low occupancy limited by static shared memorytoo much per-block tile storagereduce tile footprint or test smaller blocks, one change at a time
low occupancy limited by registers plus local-memory trafficregister pressure and spillingreduce live state or revisit unrolling, then check instruction count
adequate occupancy but high memory throughputworkload remains bandwidth-boundreduce transactions or bytes, not chase 100% occupancy
many waves but long barrier stallssynchronization or imbalanceinspect work per warp and barrier placement
too few blocks to fill SMsgrid lacks parallel worktest larger workload or different decomposition

Keep the padded variant when it removes the measured conflict and improves repeat timing without correctness or resource regression. Revert when the counter improves but end-to-end kernel timing doesn't. Counter movement isn't a product outcome by itself.

Deliberately remove the barrier

Performance edits often break synchronization before they break memory bounds. Variant broken uses the padded tile but omits __syncthreads() between shared writes and shared reads. Some warps read shared memory locations before producer warps finish writing them.

Run a smaller matrix under the tools. Compute Sanitizer documentation recommends running memcheck before racecheck because racecheck doesn't check invalid memory addresses.[7]Reference 7Compute Sanitizerhttps://docs.nvidia.com/compute-sanitizer/ComputeSanitizer/ The harness intentionally exits 2 for broken; capture that expected application failure without aborting the diagnostic sequence. Keep the full logs: --error-exitcode 99 applies to detected sanitizer errors when the application itself would otherwise succeed. A nonzero exit alone isn't evidence of a race: tool startup failures and application errors also fail the command. Identify the reported shared-memory hazard in the racecheck log.

terminal
1for tool in memcheck racecheck synccheck; do 2 if compute-sanitizer --tool "$tool" --error-exitcode 99 \ 3 ./transpose_lab broken 33 1 \ 4 > "artifacts/run-01/broken-${tool}.txt" 2>&1; then 5 echo "unexpected success for known-broken variant" >&2 6 exit 1 7 else 8 status=$? 9 echo "${tool} exit=${status}" | tee -a artifacts/run-01/broken-exits.txt 10 fi 11done

Expected diagnostic split:

  • memcheck reports no illegal addresses because every index stays within allocation bounds;
  • racecheck reports shared-memory read-after-write (RAW) hazards around the missing barrier;
  • synccheck reports no misuse because the kernel omitted a barrier instead of executing an invalid barrier;
  • output checker may fail, but an accidental correct output on one run doesn't clear the race.

That last point is critical. Scheduler timing can hide data races during an ordinary run. Dynamic race evidence invalidates a kernel even when sampled output happens to match.

Recover by restoring the block barrier. Validate every candidate you intend to keep, including a partial tile and a full-tile case. These checks run outside the performance measurement:

terminal
1for variant in copy naive tiled padded; do 2 for n in 33 1024; do 3 for tool in memcheck racecheck synccheck; do 4 compute-sanitizer --tool "$tool" --error-exitcode 99 \ 5 ./transpose_lab "$variant" "$n" 1 \ 6 > "artifacts/run-01/${variant}-${n}-${tool}.txt" 2>&1 7 done 8 done 9done 10 11./transpose_lab padded 4096 100 \ 12 | tee artifacts/run-01/recovered-benchmark.csv

The recovery record connects the symptom to the ordering requirement:

FieldMinimum content
failuremissing block barrier between shared writes and cross-warp reads
detectionracecheck hazard plus output result, whether pass or fail
repairrestored __syncthreads() reached by every thread in block
validationexact transpose, memcheck and racecheck results for the recorded cases
performancesame event-timed shape and iteration count after repair
provenanceGPU, toolkit, source hash, compiler resource output, report paths

Fill recovery-template.md and keep it with the raw reports. A reviewer should be able to trace the failure, repair, tested shapes, and post-recovery cost. Clean dynamic checks cover those executions, not every possible input or schedule.

Decide between transpose, reduction, and scan

Shared-memory tiling works well for spatial reordering. It isn't a universal answer for every memory-bound kernel. Ask what output dependency requires before choosing a primitive:

PrimitiveOutput contractNeighbor interactionTypical use
transposepreserve every value, change two-dimensional index orderthreads exchange tile positionsswitch row-major and column-major access orientation
reductioncombine many values into fewer values with associative operatorpartial aggregates mergesum loss, maximum error, count mismatches, row sum [8]Reference 8Optimizing Parallel Reduction in CUDAhttps://developer.download.nvidia.com/compute/cuda/1.1-Beta/x86_website/projects/reduction/doc/reduction.pdf
scanemit prefix aggregate for every input positioneach output depends on preceding rangeprefix offsets, stream compaction positions, cumulative token counts

Consider moving the correctness check from CPU back to GPU. If you need only total mismatch count and maximum absolute error, use reductions. Both outputs collapse N2N^2N2 comparisons into one or two scalars. A scan would do extra work by producing a prefix result for every comparison.

Now consider compacting indexes of mismatched elements. Each failing element needs a destination position based on the number of failures before it. That is a prefix dependency, so a scan computes offsets. A reduction can count total failures but can't assign each failure a unique compacted slot.

The decision rule remains mechanical:

  1. Same number of values, different layout: transpose.
  2. Fewer summary values: reduction.
  3. Same number of prefix states or compaction offsets: scan.

Tiling can appear inside all three implementations, but the dependency graph picks the algorithm. Don't start by copying a transpose tile into a reduction or scan kernel.

Report the result you measured

Complete the comparison only after the variants share the same workload and validity contract. The entries below are hypotheses and required observations, not supplied benchmark results:

RunHypothesisOne code changeCorrectnessProfiler evidenceEvent timingVerdict
naivedirect transpose wastes global-store transactionsnoneexactmemory-bound, strided store source linefive-run distributionbaseline
tiledshared tile coalesces output storesadd tile and block barrierexactglobal access improves; bank conflict appearsfive-run distributionkeep for next iteration or reject
paddedextra column changes bank mappingtile width 32 to 33exact plus sanitizerbank conflict falls; resource use recordedfive-run distributionkeep only with repeat benefit
brokenbarrier is required for cross-warp tile reuseremove barrierunreliableracecheck hazardtiming invalidreject and recover

One row isn't complete if its correctness cell says only that it looks right. Keep raw CSV, .ncu-rep files, sanitizer logs, compiler resource output, environment files, source hash, and the filled recovery record.

You've finished when you can defend all six claims:

  • workload and timing method stayed fixed;
  • baseline bound came from profiler evidence;
  • each candidate changed one causal mechanism;
  • output check matched transpose exactly;
  • sanitizer results are clean for the accepted kernels and tested shapes;
  • reported performance includes conditions and distribution, not unsupported headline speedup.
Complete the lesson

Mastery Check

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

1.A candidate transpose reports lower CUDA-event time but has three mismatched outputs. What is the correct verdict?

Correct answer: Reject candidate because correctness gate failed before performance comparison

Performance evidence counts only after same output contract passes. Faster wrong work isn't an optimization, even when profiler counter moves in expected direction.

2.Why does naive transpose produce inefficient global stores for one warp?

Correct answer: Each lane writes an address separated from next lane by matrix stride N

Within warp, x changes by one. Naive output index x * N + y therefore changes by N floats, scattering lane stores across rows instead of adjacent words.

3.For shared tile declared float tile[32][32], a warp reads one column. What bank mapping follows for lane i?

Correct answer: All lanes map different words to same bank because (32i + c) mod 32 = c

Row stride 32 disappears modulo 32. Addresses differ, so this isn't broadcast; accesses conflict on same bank.

4.What does changing shared tile from 32 by 32 to 32 by 33 accomplish?

Correct answer: It maps lane i to (i + c) mod 32, spreading column access across banks

Extra column changes shared-memory word stride to 33. Modulo 32, stride becomes one, so warp lanes reach distinct banks while output stays unchanged.

5.Which evidence best supports claim that naive transpose is memory-path limited?

Correct answer: Nsight reports memory pressure, inefficient stores, and little useful arithmetic

Bound claim needs kernel-level evidence tied to actual source and workload. Utilization, register headroom, or different copy operation doesn't isolate limiter.

6.Why isn't 100 percent occupancy an automatic optimization target?

Correct answer: Higher occupancy may not help, while forced resource cuts can cause spills

Occupancy helps hide latency, but highest value doesn't guarantee best throughput. Resource limits and instruction-level parallelism must be evaluated with timing and profiler evidence.

7.Broken variant omits block barrier, yet output happens to match once. Which next evidence decides validity?

Correct answer: Racecheck result, because sampled equality can't clear a shared-memory ordering hazard

Race can stay hidden under one schedule. Racecheck analyzes shared-memory hazards, while synccheck targets misuse of synchronization primitives rather than every omitted barrier.

8.You need one total mismatch count and one maximum error from N squared comparisons. Which primitive fits?

Correct answer: Reduction, because many comparison values collapse into few summary scalars

Count and maximum are associative aggregates. No per-element prefix result is required, so reduction matches output dependency.

9.You need compact output offsets for every mismatched element. Which primitive fits dependency?

Correct answer: Scan, because each offset depends on number of prior mismatches

Prefix count gives each selected element unique destination position. A reduction supplies only total, while transpose changes layout without computing prefix state.

10.Padded variant removes bank-conflict counter but event-time distributions overlap tiled variant. What should evidence ledger say?

Correct answer: Record mechanism gain, but report no measured timing win

Counter validates mechanism, while event timing decides observed kernel outcome. This protocol hasn't demonstrated a timing improvement, so the ledger should preserve the counter result without claiming a speedup.

10 questions remaining.

Next Step
Continue to GPU Parallel Primitives Lab

Apply indexing, synchronization, and measurement checks to reductions, scans, and softmax, where work crosses thread and block boundaries.

PreviousCorrect GPU Profiling and Benchmarking
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

NVIDIA CUDA Compiler Driver NVCC

NVIDIA · 2026 · Official documentation

https://docs.nvidia.com/cuda/cuda-compiler-driver-nvcc/

CUDA Programming Guide.

NVIDIA · 2026

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

Nsight Compute Profiling Guide

NVIDIA · 2026 · Official documentation

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

Nsight Compute CLI

NVIDIA · 2026

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

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/

Compute Sanitizer

NVIDIA · 2026 · Official documentation

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

Optimizing Parallel Reduction in CUDA

Harris, M. · 2007 · NVIDIA Developer Technology

https://developer.download.nvidia.com/compute/cuda/1.1-Beta/x86_website/projects/reduction/doc/reduction.pdf

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.