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 Parallel Primitives Lab
🚀HardInference Optimization

GPU Parallel Primitives Lab

Trace reduction, prefix scan and online softmax through concrete values, then check CUDA indexing and compare custom collectives with CUB.

26 min read
Learning path
Step 147 of 196 in the full curriculum
GPU Kernel Performance Engineering LabGPU Compilers and Kernel Languages

Personalize this lesson

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

Eight numbers sit in GPU memory: 3, -1, 4, 1, 5, -9, 2, 6. In an elementwise kernel like ReLU or bias addition, every thread works in total isolation: each lane loads one element, runs an arithmetic instruction, and writes its result straight back to memory without ever talking to a neighbor. The GPU's massive parallelism shines effortlessly.

Real deep learning operators don't get off that easily. Attention softmax, LayerNorm, RMSNorm, cross-entropy loss, and top-k sampling all force threads to combine information. Threads must pass values across registers inside a warp, across shared memory inside a thread block, and across global High Bandwidth Memory (HBM) across the grid.

These building blocks are parallel primitives, foundational collective patterns embedded inside larger kernels. Reduction folds an entire tensor or tile into a single summary scalar. Prefix scan keeps a running cumulative total at every boundary. Arg reduction tracks both the winning value and its source position. Stable softmax fuses these ideas to normalize exponentials in a single streaming pass without arithmetic overflow or intermediate round trips to off-chip memory.

The GPU Kernel Performance Engineering Lab examined memory coalescing and shared-memory bank conflicts through matrix transpose. Here, the layout is simple; the challenge is coordinating thread dependencies: which values must be written, synchronized, and published before another lane can proceed.

One total or every boundary?

Pair adjacent values in the running array [3, -1, 4, 1, 5, -9, 2, 6]. Adding neighboring pairs at the first level yields [2, 5, -4, 8]. Combining those adjacent sums at the second level yields [7, 4]. The final addition produces 11. A balanced reduction tree with nnn leaves requires n−1n-1n−1 additions and log⁡2n\log_2 nlog2​n sequential combining steps.

A prefix scan asks for a completely different contract: it keeps every intermediate boundary. An exclusive sum at index iii accumulates elements strictly before index iii, placing the additive identity 0 at index 0. An inclusive sum also includes xix_ixi​. For the first four elements [3, -1, 4, 1], the exclusive prefix sum is [0, 3, 2, 6] and the inclusive prefix sum is [3, 2, 6, 7].

Eight inputs 3,-1,4,1,5,-9,2,6 reduce into interval sums 2,5,-4,8, then 7,4, then 11. Cell widths double as intervals double, collapsing to one total. Prefix scan preserves eight aligned columns: exclusive prefixes 0,3,2,6,7,12,3,5 and inclusive prefixes 3,2,6,7,12,3,5,11.
Parallel reduction collapses input intervals into a single scalar root (Many-to-One), while prefix scan preserves every aligned output slot to track cumulative boundaries (Many-to-Many).

For exact real addition, the two scan conventions connect through a direct identity:

inclusive⁡[i]=exclusive⁡[i]+xi.\operatorname{inclusive}[i]=\operatorname{exclusive}[i]+x_i.inclusive[i]=exclusive[i]+xi​.

Arg reduction carries a structured payload: (value=6, index=7). When multiple positions share the maximum, you must pair the comparison operator with a deterministic tie-breaking rule, such as picking the lowest index. The Python reference below verifies all three primitive contracts before introducing GPU hardware details.

primitive-reference.py
1x = [3, -1, 4, 1, 5, -9, 2, 6] 2exclusive, inclusive, total = [], [], 0 3for value in x: 4 exclusive.append(total) 5 total += value 6 inclusive.append(total) 7 8def argmax(values): 9 if not values: 10 raise ValueError("Argmax requires a nonempty sequence") 11 index = max(range(len(values)), key=lambda i: (values[i], -i)) 12 return values[index], index 13 14assert total == 11 15assert exclusive == [0, 3, 2, 6, 7, 12, 3, 5] 16assert inclusive == [3, 2, 6, 7, 12, 3, 5, 11] 17assert argmax(x) == (6, 7) 18assert argmax([6, -1, 6]) == (6, 0) 19print("sum:", total) 20print("exclusive:", exclusive) 21print("inclusive:", inclusive) 22print("argmax (value, index):", argmax(x))
Output
1sum: 11 2exclusive: [0, 3, 2, 6, 7, 12, 3, 5] 3inclusive: [3, 2, 6, 7, 12, 3, 5, 11] 4argmax (value, index): (6, 7)

Reduce within a warp using shuffle intrinsics

Early GPU reduction kernels relied heavily on shared memory: threads wrote inputs into a __shared__ array, synchronized via __syncthreads(), and accumulated across stride intervals. That approach cost shared memory capacity, suffered from bank conflicts when strided accesses hit identical memory banks, and stalled on block barriers at every tree level.

Modern CUDA warps (32 threads) bypass shared memory completely by using warp shuffle intrinsics like __shfl_down_sync. Shuffle instructions let threads read register values directly from other lanes within the same warp across the streaming multiprocessor's crossbar, delivering single-cycle register-to-register communication.[1]Reference 1C/C++ Language Extensionshttps://docs.nvidia.com/cuda/cuda-programming-guide/05-appendices/cpp-language-extensions.html

Offsets 16, 8, 4, 2, 1 construct a five-stage reduction tree across all 32 lanes. Lane 0 accumulates the warp total, while higher lanes hold intermediate partials:

warp-sum.cu
1__device__ long long warp_sum(long long value) { 2 constexpr unsigned kFullMask = 0xffffffffu; 3 const unsigned lane = threadIdx.x % warpSize; 4 for (int offset = warpSize / 2; offset > 0; offset /= 2) { 5 const long long other = __shfl_down_sync(kFullMask, value, offset); 6 if (lane + offset < warpSize) { 7 value += other; 8 } 9 } 10 return value; // Full aggregate is valid in lane 0. 11}

Two hardware rules govern this kernel. First, starting with the Volta architecture, NVIDIA GPUs run with Independent Thread Scheduling. Before Volta, all 32 threads in a warp shared a single program counter and executed in lockstep. On modern architectures, threads maintain independent program counters and call stacks. Omitting a mask or relying on implicit lockstep execution leads to divergence bugs. The active mask kFullMask = 0xffffffffu explicitly forces all 32 lanes to synchronize at each shuffle step.[2]Reference 2CUDA Programming Guide.https://docs.nvidia.com/cuda/cuda-programming-guide/

Second, participating in the shuffle is separate from adding the result. If a warp processes only 20 active inputs, threads 20 through 31 shouldn't exit early. In CUDA's shuffle contract, reading from an exited or non-participating lane returns an undefined value or the caller's own value. If lane 4 reads from lane 4+16=204+16=204+16=20 and lane 20 has exited, lane 4 receives its own value and doubles it. Feeding 0 into inactive lanes and running the full mask guarantees that every source lane contributes the additive identity. The conditional addition if (lane + offset < warpSize) belongs strictly after the shuffle instruction so that all lanes execute the collective together.

The CPU model verifies this behavior by checking lane participation, missing source rejections, and signed 64-bit integer overflow protection:

warp-model.py
1from assets.primitives_model import warp_sum, I64_MAX, expect_error 2 3lanes = [1] * 20 + [0] * 12 4assert warp_sum(lanes) == 20 5expect_error(ValueError, lambda: warp_sum(lanes, active=range(20))) 6assert warp_sum([0] * 31 + [I64_MAX]) == I64_MAX 7expect_error(OverflowError, lambda: warp_sum([I64_MAX, 1] + [0] * 30)) 8print("20 values + 12 participating zero lanes: sum 20") 9print("Absent source rejected; representable and overflowing int64 sums distinguished")
Output
120 values + 12 participating zero lanes: sum 20 2Absent source rejected; representable and overflowing int64 sums distinguished

To aggregate across a 256-thread block (8 warps), lane 0 of each warp deposits its warp total into __shared__ long long warp_sums[8]. A block-wide barrier __syncthreads() guarantees all 8 warps finish their writes before warp 0 reads the shared array. Warp 0's first 8 lanes load the 8 partial sums, while lanes 8 through 31 load zero. A second warp_sum folds those 8 partials, leaving the entire block total in lane 0:

block-reduction-core.cu
1sum = warp_sum(sum); 2if (lane == 0) { 3 warp_sums[warp] = sum; 4} 5__syncthreads(); 6if (warp == 0) { 7 long long block_sum = lane < 8 ? warp_sums[lane] : 0; 8 block_sum = warp_sum(block_sum); 9 if (lane == 0) { 10 block_sums[blockIdx.x] = block_sum; 11 } 12}

Notice the barrier placement: __syncthreads() sits outside all warp-divergent branches. Placing a block barrier inside if (lane == 0) or if (warp == 0) triggers undefined behavior because only a fraction of the block's threads reach the barrier.

Aggregate partials across blocks: multi-pass kernels versus global atomics

Each thread in the block loads two elements: one at block_start + threadIdx.x and one 256 elements later. Consecutive threads access consecutive memory addresses, ensuring fully coalesced global memory loads.[3]Reference 3Optimizing Parallel Reduction in CUDAhttps://developer.download.nvidia.com/compute/cuda/1.1-Beta/x86_website/projects/reduction/doc/reduction.pdf A 256-thread block consumes 512 input elements and emits a single 64-bit block partial.

To reduce across thousands of blocks, why can't we just synchronize the entire grid inside one kernel? Because GPUs don't provide a general grid-wide barrier across independent thread blocks. Blocks execute dynamically across available Streaming Multiprocessors. If a kernel paused waiting for unlaunched blocks that can't fit on the SMs due to occupancy limits, the GPU would deadlock.

Two architectural strategies solve this grid-scale aggregation:

  1. Multi-pass hierarchical kernel launches: Each kernel pass reduces elements by a factor of 512. For 4,194,304 inputs:
reduction-passes.txt
14,194,304 inputs -> 8,192 partials -> 16 partials -> 1 scalar 2 pass 1 pass 2 pass 3

Because the host queues all three launches in the same CUDA stream, the hardware guarantees they execute sequentially without race conditions. Each pass streams data in wide, coalesced bursts across all memory channels, avoiding write contention.

  1. Global atomics (atomicAdd): In this approach, each block reduces its local tile and uses atomicAdd(&global_sum, block_sum) to update a single output location in global memory.
  • When atomics make sense: If the grid has few blocks, or if you're reducing into many independent output bins (like histogramming or channel-wise normalization), atomic contention is minimal.
  • When atomics crawl: When thousands of blocks all finish nearly simultaneously and attempt an atomicAdd on the exact same 64-bit address, the memory controllers in the L2 cache slice serialize all requests. The pipeline stalls waiting for read-modify-write transactions to queue up.

The hierarchical multi-pass approach eliminates serialization entirely, trading small launch overhead for clean bandwidth scaling.

Floating-point trees can disagree without a race

Integer addition is associative and commutative, so grouping and ordering don't alter the mathematical result (provided no intermediate overflows). Real numbers are also associative, but IEEE 754 floating-point addition rounds after every single operation. Floating-point addition is commutative (a+b=b+aa + b = b + aa+b=b+a), but it's not associative:

(a+b)+c≠a+(b+c).(a + b) + c \neq a + (b + c).(a+b)+c=a+(b+c).

The standard library example below forces 32-bit float truncation to reveal how operand ordering changes low bits on identical inputs:

float-trees.py
1from struct import pack, unpack 2from math import fsum 3 4def f32(value): 5 return unpack("f", pack("f", value))[0] 6 7a, b, c = f32(100_000_000), f32(1), f32(-100_000_000) 8left_grouped = f32(f32(a + b) + c) 9cancel_first = f32(f32(a + c) + b) 10assert left_grouped == 0.0 11assert cancel_first == 1.0 12assert fsum([a, b, c]) == 1.0 13print("(a + b) + c:", left_grouped) 14print("(a + c) + b:", cancel_first) 15print("Higher-precision reference:", fsum([a, b, c]))
Output
1(a + b) + c: 0.0 2(a + c) + b: 1.0 3Higher-precision reference: 1.0

In a parallel GPU reduction, thread block execution order is nondeterministic across SMs. If blocks complete in different orders, or if a dynamic schedule alters which block partials combine first, the low-order mantissa bits vary between runs.

This bitwise difference doesn't mean there's a race condition or a bug. It's the natural consequence of floating-point non-associativity across dynamic parallel trees.

Repeatability and numerical accuracy are two distinct properties:

  • Repeatability (determinism) guarantees that running the kernel twice on the same input produces the exact same bit pattern. CUB's DeviceReduce provides run_to_run determinism by fixing the internal tile reduction order.[4]Reference 4cub::DeviceReducehttps://nvidia.github.io/cccl/unstable/cub/api/structcub_1_1DeviceReduce.html But bitwise repeatability doesn't prove the result is close to the true mathematical sum.
  • Accuracy measures how close the computed float is to infinite-precision arithmetic. A balanced pairwise summation tree is generally far more accurate than serial accumulation because it avoids adding tiny numbers to massive running totals, even if scheduling differences cause tiny bit variations across runs. When verifying float kernels, always compare against a higher-precision reference using explicit relative and absolute tolerances rather than testing for bitwise equality.

Parallel scan: Hillis-Steele step efficiency versus Blelloch work efficiency

While reduction collapses an array into one value, prefix scan distributes cumulative prefixes back to every element. Two classic parallel algorithms tackle prefix scan with different complexity trade-offs:

  • Hillis-Steele (Step-efficient): In each step kkk (with stride 2k2^k2k), every active thread iii adds the element at i−2ki - 2^ki−2k. It completes in only log⁡2n\log_2 nlog2​n steps. However, every step performs additions across almost the entire array, yielding O(nlog⁡n)O(n \log n)O(nlogn) total work. Hillis-Steele is ideal inside a single 32-lane warp via __shfl_up_sync: because all 32 lanes execute in parallel on the warp's ALUs in 5 steps, the extra additions don't waste instruction cycles because inactive lanes are merely masked out.

  • Blelloch (Work-efficient): Blelloch scan takes 2log⁡2n2 \log_2 n2log2​n steps, but performs only O(n)O(n)O(n) additions (2n−22n - 22n−2 operations total). It splits the scan into two distinct phases in shared memory:

  1. Up-Sweep (Reduce): A binary reduction tree where stride doubles each round (1, 2, 4, ...). Threads add values upward until the root (last array position) holds the grand total.
  2. Down-Sweep (Distribute): Save the root total, overwrite the root with 0 (the additive identity), and reverse the tree with stride halving (... 4, 2, 1). At each step, a node sends its current value to its left child, while its right child receives the sum of its old left child and its current value.

For a 512-item block, Hillis-Steele requires roughly 512×9≈4,608512 \times 9 \approx 4,608512×9≈4,608 additions, whereas Blelloch needs only 2×512−2=1,0222 \times 512 - 2 = 1,0222×512−2=1,022 additions: a 4.5×4.5\times4.5× reduction in shared-memory traffic and arithmetic operations.[5]Reference 5Single-pass Parallel Prefix Scan with Decoupled Look-backhttps://research.nvidia.com/sites/default/files/pubs/2016-03_Single-pass-Parallel-Prefix/nvr-2016-002.pdf

The trace below captures the exact array mutations during Blelloch exclusive scan on [3, -1, 4, 1]:

scan-phases.py
1from assets.primitives_model import block_scan, device_scan 2 3trace = [] 4local, total = block_scan([3, -1, 4, 1], width=4, trace=trace) 5for phase, values in trace: 6 print(f"{phase:8}: {values}") 7assert local == [0, 3, 2, 6] and total == 7 8x = [3, -1, 4, 1, 5, -9, 2, 6] 9assert block_scan(x[4:], width=4)[0] == [0, 5, -4, -2] 10assert device_scan(x, width=4) == [0, 3, 2, 6, 7, 12, 3, 5] 11print("Device-wide:", device_scan(x, width=4))
Output
1up 1 : [3, 2, 4, 5] 2up 2 : [3, 2, 4, 7] 3root = 0: [3, 2, 4, 0] 4down 2 : [3, 0, 4, 2] 5down 1 : [0, 3, 2, 6] 6Device-wide: [0, 3, 2, 6, 7, 12, 3, 5]

Propagate block offsets: multi-pass hierarchies versus decoupled look-back

A local Blelloch scan computes correct prefixes within each block, but every block starts at local offset 0. To stitch them into a device-wide prefix scan, each block must receive the cumulative sum of all preceding blocks.

In a hierarchical multi-pass scan:

  1. Each block scans its 512 elements, emits local prefixes, and writes its block total to a global block_sums buffer. For blocks with inputs [3,-1,4,1] and [5,-9,2,6], the block totals are [7, 4].
  2. A second kernel scans block_sums to compute block offsets: exclusive_scan([7, 4]) = [0, 7].
  3. A third kernel broadcasts each block's offset across its threads: Block 0 adds +0, while Block 1 adds +7 to all its local prefixes, converting [0, 5, -4, -2] into [7, 12, 3, 5].
Two four-item blocks have totals 7 and 4, whose exclusive scan yields block offsets 0 and 7. Block 0 local prefixes [0,3,2,6] receive offset +0. Block 1 local prefixes [0,5,-4,-2] receive broadcast offset +7, producing global exclusive prefixes [7,12,3,5].
Local block scans independently compute exclusive prefixes starting at zero. A second-pass scan across block totals computes base offsets, broadcasting +7 to repair Block 1 global prefixes.

Testing scan hierarchies requires exercising recursion boundaries. If your block size is 512, an input of 5122=262,144512^2 = 262,1445122=262,144 elements produces exactly 512 block totals, which fit in a single metadata block. But 5122+1=262,145512^2 + 1 = 262,1455122+1=262,145 elements produce 513 block totals, spilling into two metadata blocks and triggering another recursion level. Testing only small powers of two misses that structural threshold.

Decoupled Look-Back (Single-Pass Scan): Multi-pass scan requires three kernel launches and writes all intermediate block totals to global memory. NVIDIA CUB's DeviceScan eliminates intermediate launches using decoupled look-back (Merrill & Garland 2016):[5]Reference 5Single-pass Parallel Prefix Scan with Decoupled Look-backhttps://research.nvidia.com/sites/default/files/pubs/2016-03_Single-pass-Parallel-Prefix/nvr-2016-002.pdf

  • Blocks grab dynamic tile indices via an atomic counter.
  • Each block computes its local reduction aggregate and publishes a status flag in global memory:
    • X: Uninitialized.
    • A: Local aggregate computed and published.
    • P: Inclusive prefix across all prior tiles computed and published.
  • When block kkk finishes its local aggregate, it looks back at tile k−1k-1k−1:
    • If tile k−1k-1k−1 is marked P, block kkk reads the prefix directly, adds its local aggregate, and publishes its own status P.
    • If tile k−1k-1k−1 is marked A, block kkk adds tile k−1k-1k−1's aggregate and looks further back to tile k−2k-2k−2, accumulating aggregates until it reaches a tile marked P.

Decoupled look-back achieves a single-pass device scan with approximately nnn global memory reads and nnn global memory writes, overlapping prefix propagation directly with tile computation.[6]Reference 6cub::DeviceScanhttps://nvidia.github.io/cccl/unstable/cub/api/structcub_1_1DeviceScan.html

Online softmax carries a scale with its sum

Softmax converts an array of unnormalized logits xix_ixi​ into a probability distribution:

pi=exi−m∑jexj−m,m=max⁡jxj.p_i = \frac{e^{x_i - m}}{\sum_j e^{x_j - m}}, \qquad m = \max_j x_j.pi​=∑j​exj​−mexi​−m​,m=jmax​xj​.

Subtracting the maximum mmm prevents floating-point overflow when evaluating exponentials.

A conventional 3-pass GPU softmax makes three full trips across global memory (HBM):

  1. Reduction pass: find the global maximum m=max⁡jxjm = \max_j x_jm=maxj​xj​.
  2. Reduction pass: compute the normalizer denominator d=∑jexj−md = \sum_j e^{x_j - m}d=∑j​exj​−m.
  3. Elementwise pass: compute each probability pi=exi−m/dp_i = e^{x_i - m} / dpi​=exi​−m/d.

When tensors are large, those three memory round trips throttle performance on memory-bandwidth-bound GPUs.

The online normalizer (Milakov & Gimelshein 2018) merges the maximum search and exponential summation into a single pass.[7]Reference 7Online normalizer calculation for softmax.https://arxiv.org/abs/1805.02867 It maintains a running state tuple (m,d)(m, d)(m,d), where mmm is the running maximum and ddd is the exponential sum measured relative to that maximum.

When merging two partial states (ma,da)(m_a, d_a)(ma​,da​) and (mb,db)(m_b, d_b)(mb​,db​), the combined maximum is m=max⁡(ma,mb)m = \max(m_a, m_b)m=max(ma​,mb​). Before adding the sums, each denominator must be rescaled to match the new reference point:

m=max⁡(ma,mb),d=daema−m+dbemb−m.m = \max(m_a, m_b), \qquad d = d_a e^{m_a - m} + d_b e^{m_b - m}.m=max(ma​,mb​),d=da​ema​−m+db​emb​−m.

For our running array partitioned into two halves, xleft=[3,−1,4,1]x_{\text{left}} = [3, -1, 4, 1]xleft​=[3,−1,4,1] has ma=4m_a = 4ma​=4 and da≈1.424404d_a \approx 1.424404da​≈1.424404. The right half xright=[5,−9,2,6]x_{\text{right}} = [5, -9, 2, 6]xright​=[5,−9,2,6] has mb=6m_b = 6mb​=6 and db≈1.386195d_b \approx 1.386195db​≈1.386195. When merging, the global maximum becomes 6. The left denominator shrinks by e4−6=e−2≈0.135335e^{4 - 6} = e^{-2} \approx 0.135335e4−6=e−2≈0.135335 to become 0.1927720.1927720.192772, while the right denominator stays unchanged. Adding them gives the exact combined denominator 1.5789681.5789681.578968.

The left tile state (m=4, d=1.424404) rescales by exp(-2) to contribute 0.192772 (12.2%). The right tile state (m=6, d=1.386195) contributes 1.386195 (87.8%). Their rescaled sum forms the exact single-pass denominator 1.578968.
Online normalizer merges two tile states by adopting the unified maximum m = 6 and decaying Tile A denominator by exp(4 - 6). Both sums then share a consistent scale without global memory round trips.
softmax-partitions.py
1from math import exp, inf, isclose, fsum 2from assets.primitives_model import softmax_state, merge_states, softmax, expect_error 3 4x = [3, -1, 4, 1, 5, -9, 2, 6] 5left, right = softmax_state(x[:4]), softmax_state(x[4:]) 6merged = merge_states(left, right) 7assert merged[0] == 6 8assert isclose(merged[1], fsum(exp(v - 6) for v in x), rel_tol=1e-15) 9assert isclose(fsum(softmax(x)), 1.0, abs_tol=1e-15) 10assert softmax([None, 5, None]) == [0, 1, 0] 11assert softmax([-10000, 0, 10000]) == [0, 0, 1] 12assert merge_states((-inf, 0), (-inf, 0)) == (-inf, 0) 13expect_error(ValueError, lambda: softmax([None, None])) 14expect_error(ValueError, lambda: softmax([inf, 1])) 15for a, b in zip(softmax(x), softmax([v + 1000 for v in x])): 16 assert isclose(a, b, rel_tol=1e-14, abs_tol=1e-15) 17print(f"left: m={left[0]}, d={left[1]:.6f}") 18print(f"right: m={right[0]}, d={right[1]:.6f}") 19print(f"merged: m={merged[0]}, d={merged[1]:.6f}") 20print("Shift invariance, extreme logits, empty identity and masking checked")
Output
1left: m=4, d=1.424404 2right: m=6, d=1.386195 3merged: m=6, d=1.578968 4Shift invariance, extreme logits, empty identity and masking checked

How FlashAttention builds on the online normalizer: This online state update is the foundation behind FlashAttention (Dao et al., 2022).[8]Reference 8FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness.https://arxiv.org/abs/2205.14135 Standard attention computes S=QKTS = QK^TS=QKT, takes the full softmax P=softmax⁡(S)P = \operatorname{softmax}(S)P=softmax(S), and multiplies by VVV, materializing an N×NN \times NN×N attention matrix in HBM (O(N2)O(N^2)O(N2) memory traffic).

FlashAttention loads blocks of QQQ, KKK, and VVV into fast SRAM (shared memory/registers). For each block of keys and values, it computes local dot products StileS_{\text{tile}}Stile​, updates the running online softmax state (m,d)(m, d)(m,d), and rescales the accumulated output accumulator OOO in registers:

Onew=diag⁡(emold−mnew)Oold+eStile−mnewVtile.O_{\text{new}} = \operatorname{diag}\left(e^{m_{\text{old}} - m_{\text{new}}}\right) O_{\text{old}} + e^{S_{\text{tile}} - m_{\text{new}}} V_{\text{tile}}.Onew​=diag(emold​−mnew​)Oold​+eStile​−mnew​Vtile​.

At the end of the sequence, it divides OOO by the final accumulated denominator ddd. The N×NN \times NN×N attention probabilities are never written to HBM, shrinking memory footprint from O(N2)O(N^2)O(N2) to O(N)O(N)O(N) and turning bandwidth-choked attention into compute-bound tensor core math.

Race conditions and verification: sanitizing shared-memory hazards

Parallel primitives push shared memory to its limit. Threads within a block constantly exchange data through shared arrays. Writing correct parallel primitives requires catching three subtle classes of data hazards:

  • RAW (Read-After-Write): Thread A writes to a shared index, but Thread B reads before the write finishes.
  • WAR (Write-After-Read): Thread A reads from a shared location, but Thread B overwrites that location before Thread A finishes loading.
  • WAW (Write-After-Write): Multiple threads attempt to write to the same shared address without a deterministic sequence.

NVIDIA's Compute Sanitizer provides specialized tools to detect these defects at the machine-instruction level:[9]Reference 9Compute Sanitizerhttps://docs.nvidia.com/compute-sanitizer/ComputeSanitizer/

terminal
1compute-sanitizer --tool memcheck --error-exitcode=99 ./primitives_lab 4099 1 2compute-sanitizer --tool racecheck --error-exitcode=99 ./primitives_lab 4099 1 3compute-sanitizer --tool synccheck --error-exitcode=99 ./primitives_lab 4099 1

Each tool addresses a specific failure mode:

  1. synccheck validates barrier usage. If threads within a block call __syncthreads() inside divergent branches (where some threads participate while others take an alternative path), the hardware can deadlock or hang. synccheck verifies that all non-exited threads in a block reach the identical barrier instruction.
  2. racecheck tracks shared memory accesses dynamically. It records the cycle and warp of every load and store. If two threads access the same shared memory location without an intervening __syncthreads() or __syncwarp(), racecheck flags the exact instruction and line number.
  3. memcheck detects out-of-bounds reads and writes in global and shared memory, plus unaligned memory accesses.

Keep two verification rules firmly separated:

  • A clean sanitizer run doesn't prove arithmetic correctness. If your scan kernel forgets to add block offsets, racecheck reports 0 hazards because there are no race conditions, but every output value in block 1 will be wrong.
  • A passing test run doesn't prove race-freedom. On an idle GPU running a small test, warps might serendipitously execute in order and produce the correct output. But under production load with multiple kernels competing for SM resources, that hidden race condition will corrupt output buffers. Always run both reference assertions and Compute Sanitizer tools.

Select the primitive by its output contract

Different workloads demand different output contracts:

Needed outputPrimitiveContract to specify
One aggregate for an arrayreductionidentity element, accumulator type, associativity
Cumulative offset at every indexexclusive scanidentity, input ordering, operator
Cumulative aggregate including indexinclusive scanoperator, boundary definition
One aggregate per variable-length segmentsegmented reductionsegment flag array, empty segment identity
Winning value and source positionarg reductiontie-breaking rule, NaN policy, index type
Streaming normalizer without intermediatesonline softmaxrunning maximum, decaying sum, empty state

For standalone collective operations on whole arrays, prefer NVIDIA CUB (cub::DeviceReduce, cub::DeviceScan, cub::BlockScan).[10]Reference 10CUB Developer Overviewhttps://nvidia.github.io/cccl/unstable/cub/developer_overview.html CUB is part of NVIDIA's CUDA Core Compute Libraries (CCCL). It features tuned architecture-specific policies, auto-tuning for different GPU generations, and optimized decoupled look-back implementations.

CUB's device-wide APIs use a two-step allocation pattern: query the required scratchpad size with a nullptr, allocate memory, then execute:

cub-sum.cu
1void* temporary = nullptr; 2std::size_t temporary_bytes = 0; 3CHECK_CUDA(cub::DeviceReduce::Sum( 4 temporary, temporary_bytes, input, output, count, stream)); 5CHECK_CUDA(cudaMalloc(&temporary, temporary_bytes)); 6CHECK_CUDA(cub::DeviceReduce::Sum( 7 temporary, temporary_bytes, input, output, count, stream));

When should you write a custom collective kernel instead of calling CUB?

  1. Kernel fusion: If reduction or scan is only one step of a larger workflow (such as online softmax inside attention, LayerNorm, or RoPE), calling CUB forces you to write intermediate tensors to HBM and launch separate kernels. Fusing the primitive directly inside your custom kernel keeps data in registers and shared memory.
  2. Specialized tensor layouts: If your data resides in register-tiled GEMM fragments or strided multi-dimensional buffers that CUB's linear iterators can't map, custom warp and block primitives are essential.

Run the CUDA lab and verify correctness boundaries

Download the CUDA source code into your working environment:

  • The CUDA lab source
  • Host validation header
  • CPU algorithm model
  • Historical benchmark receipt

Compile with full optimization and line information:

terminal
1: "${GPU_ARCH:?Set GPU_ARCH to your installed GPU target, for example sm_80}" 2nvcc -O3 -std=c++17 -lineinfo -arch="$GPU_ARCH" \ 3 assets/primitives_lab.cu -o primitives_lab 4./primitives_lab 4194304 30

Before benchmarking, run correctness sweeps across warp boundaries (31, 32, 33), block boundaries (255, 256, 257, 511, 512, 513), and recursive scan boundaries (5122+1=262,145512^2 + 1 = 262,1455122+1=262,145):

terminal
1for n in 1 31 32 33 255 256 257 511 512 513 4099 262145; do 2 ./primitives_lab "$n" 1 || exit 1 3done

CPU checks you can run immediately

The companion Python model verifies these same index boundaries, checking 17 hierarchy sizes, 260 recursive scan cases, signed overflow handling, and online softmax partition merges:

primitive-model-checks.py
1from assets.primitives_model import run_checks 2run_checks()
Output
1CPU models: 17 hierarchy sizes and 260 recursive scan cases pass 2Overflow, absent shuffle sources, invalid widths and masked/nonfinite rows checked 3No CUDA compilation, GPU synchronization, sanitizer or performance claim

Interpret timing without confusing traffic and bandwidth

The benchmark measures elapsed time using CUDA events across 30 iterations after 5 warmup cycles. Allocation and initial host-to-device transfers are excluded from timing.

The benchmark reports logical byte rate:

  • For reduction: 4N4N4N input bytes divided by elapsed time.
  • For scan: 4N4N4N input bytes plus 8N8N8N output bytes (12N12N12N bytes total) divided by elapsed time.

This logical rate reflects algorithmic data volume, not physical DRAM transactions. It doesn't include intermediate scratchpad writes or cache hits in L2. Always record compiler flags, CUDA toolkit versions, GPU architecture, and profiler metrics (such as memory throughput from NCU) when reporting performance numbers.

Review questions

1. Why does warp shuffle avoid shared memory bank conflicts during intra-warp reduction?

Shuffle intrinsics exchange register contents directly over hardware crossbar interconnects within the 32-lane warp. They require zero shared memory allocation, generate no memory transactions, and eliminate block-wide __syncthreads() barriers.

2. What is the fundamental algorithmic tradeoff between Hillis-Steele and Blelloch parallel scan?

Hillis-Steele executes log⁡2N\log_2 Nlog2​N steps with O(Nlog⁡2N)O(N \log_2 N)O(Nlog2​N) total additions, prioritizing step efficiency at the expense of work efficiency. Blelloch splits execution into Up-Sweep reduction and Down-Sweep distribution phases taking 2log⁡2N2 \log_2 N2log2​N steps but only O(N)O(N)O(N) operations, matching serial work complexity.

3. Why do floating-point parallel reductions yield non-identical results across different tile configurations?

IEEE 754 floating-point addition is non-associative: (a+b)+c≠a+(b+c)(a + b) + c \neq a + (b + c)(a+b)+c=a+(b+c). Altering thread block dimensions, tile sizes, or reduction tree hierarchies shifts intermediate rounding boundaries, producing slight bitwise discrepancies even in strictly race-free code.

4. How does decoupled look-back eliminate multi-pass kernel launches in global prefix scans?

In decoupled look-back, blocks publish their local status and running totals into global state flags using release consistency. Downstream blocks inspect upstream flags directly, either consuming completed prefixes or accumulating partial aggregates dynamically without terminating the kernel grid.

5. Why does online softmax rescale running accumulators when updating the maximum?

Online softmax applies the algebraic identity ex−mnew=ex−mold⋅emold−mnewe^{x - m_{\text{new}}} = e^{x - m_{\text{old}}} \cdot e^{m_{\text{old}} - m_{\text{new}}}ex−mnew​=ex−mold​⋅emold​−mnew​. When encountering an element larger than the current maximum, the kernel rescales its existing exponential sum in registers by emold−mnewe^{m_{\text{old}} - m_{\text{new}}}emold​−mnew​, maintaining numerical stability in a single pass without intermediate HBM writes.

Complete the lesson

Mastery Check

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

1.The pair sums of [3,-1,4,1,5,-9,2,6] are [2,5,-4,8]. What are the next reduction level's two values?

Correct answer: [7,4]

Combine adjacent pair sums: 2+5=7 and -4+8=4. The final addition gives 11.

2.Which is the exclusive sum scan of [3,-1,4,1] with identity zero?

Correct answer: [0,3,2,6]

Each output is the sum strictly before its position. The final input 1 isn't included in the last exclusive prefix.

3.Why does the CUDA lab keep out-of-range input lanes participating with value zero?

Correct answer: Requested source lanes remain defined and contribute identities

The tree requests values from participating lanes. A mask alone supplies neither a valid source value nor the additive identity.

4.The two local block scans are [0,3,2,6] and [0,5,-4,-2]. Their block totals are [7,4]. What repairs the second block?

Correct answer: Add 7 to every output

The exclusive scan of block totals is [0,7]. Every position in block 1 must include the entire earlier block.

5.Why test N=512²+1 as well as N=511,512,513 for this hierarchical scan?

Correct answer: It forces block totals to need another scan level

262145 inputs produce 513 first-level block totals. Scanning those totals requires more than one metadata block and another propagation level.

6.Online-softmax states are (maximum=5, denominator=2) and (maximum=6, denominator=1). What is the merged denominator relative to 6?

Correct answer: 2e^-1+1

The left state changes scale from maximum 5 to 6, multiplying its denominator by e^-1. The right state's scale is unchanged.

7.A CPU index model passes every scan case. Which remaining concern needs actual GPU validation?

Correct answer: Whether real threads execute the required barriers correctly

A CPU phase model assumes completed synchronization. It can't establish CUDA scheduling, memory visibility, races or generated-code behavior.

8.A standalone scan already matches CUB's contract. Which reason most strongly motivates investigating a custom kernel?

Correct answer: Fusion can avoid writing a surrounding intermediate

Fusion may eliminate memory traffic or launches in the complete computation. It still needs a correctness-matched comparison with the equivalent CUB composition.

9.Two float sum kernels meet a tolerance, but only one repeats bitwise. What follows?

Correct answer: Accuracy and repeatability are separate measured properties

Rounding order can change low bits without a race. A fixed tree can reproduce a less accurate answer.

10.The scan benchmark divides 12N bytes by elapsed time. What does that rate represent?

Correct answer: Logical int32 input plus int64 output per second

The 12N numerator counts four input and eight output bytes per element. It omits partial/offset traffic and doesn't distinguish cache from DRAM service.

10 questions remaining.

Next Step
Continue to GPU Compilers and Kernel Languages

Trace how these indexing, warp shuffle, and synchronization patterns compile down into PTX virtual assembly, hardware SASS instructions, and register allocations.

PreviousGPU Kernel Performance Engineering Lab
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

C/C++ Language Extensions

NVIDIA · 2026

https://docs.nvidia.com/cuda/cuda-programming-guide/05-appendices/cpp-language-extensions.html

CUDA Programming Guide.

NVIDIA · 2026

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

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

cub::DeviceReduce

NVIDIA · 2026 · CUDA Core Compute Libraries documentation

https://nvidia.github.io/cccl/unstable/cub/api/structcub_1_1DeviceReduce.html

Single-pass Parallel Prefix Scan with Decoupled Look-back

Merrill, D. and Garland, M. · 2016 · NVIDIA Technical Report NVR-2016-002

https://research.nvidia.com/sites/default/files/pubs/2016-03_Single-pass-Parallel-Prefix/nvr-2016-002.pdf

cub::DeviceScan

NVIDIA · 2026 · CUDA Core Compute Libraries documentation

https://nvidia.github.io/cccl/unstable/cub/api/structcub_1_1DeviceScan.html

Online normalizer calculation for softmax.

Milakov, M. & Gimelshein, N. · 2018

https://arxiv.org/abs/1805.02867

FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness.

Dao, T., Fu, D. Y., Ermon, S., Rudra, A., & Ré, C. · 2022 · NeurIPS 2022

https://arxiv.org/abs/2205.14135

Compute Sanitizer

NVIDIA · 2026 · Official documentation

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

CUB Developer Overview

NVIDIA · 2026 · CUDA Core Compute Libraries documentation

https://nvidia.github.io/cccl/unstable/cub/developer_overview.html

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.