CUDA Programming Series — Tutorial 05

Matrix Multiplication — Naive to Tiled

Step-by-step optimisation from a naive O(n³) global-memory kernel to a shared-memory tiled implementation, with complete compilable code and performance analysis.

CUDA Matrix Multiplication Shared Memory Tiling Optimisation Performance
Why MatMul → Naive Kernel → Analysis → Tiling Concept → Tiled Kernel → Benchmarks → Further Opts → Exercises
00

Topics We'll Cover

This tutorial takes you from a straightforward but slow matrix multiplication to a high-performance tiled implementation using shared memory. We write complete, compilable CUDA programs at each step.

Prerequisites

Tutorials 01–04 of this series — you should be comfortable with kernel launches, thread indexing, memory allocation (cudaMalloc/cudaMemcpy), and the basics of shared memory.

01

Why Matrix Multiply?

Matrix multiplication is the single most important kernel in GPU computing. It sits at the heart of deep learning, scientific simulation, computer graphics, and signal processing.

Where MatMul Appears

Neural Networks

Every fully-connected layer, every attention head, every convolution (via im2col) is a matrix multiply. Training and inference are dominated by GEMM.

Scientific HPC

Finite element methods, molecular dynamics, fluid simulation — all rely on dense and sparse linear algebra with MatMul at the core.

Computer Graphics

Transformations, projections, lighting calculations — every vertex passes through matrix-vector and matrix-matrix products.

Why It's Perfect for GPUs

For matrices of size N×N, matrix multiplication requires O(N³) arithmetic operations on O(N²) data. This high compute-to-data ratio means there is massive parallelism: each of the N² output elements can be computed independently.

N² output elements
→
N² independent threads
→
Each does N multiply-adds
Key Insight

MatMul is embarrassingly parallel at the output level — but memory-bound if you're not careful about data reuse. The gap between a naive and optimised implementation can be 10× or more.

02

Naive Implementation

The simplest approach: one thread per output element. Each thread reads an entire row of A and an entire column of B from global memory, computes the dot product, and writes the result to C.

The Problem with Global Memory

Thread (row, col)
→
Read N elements from A row
→
Read N elements from B col
→
Write 1 element to C

Every element of A is read N times (once per column of C). Every element of B is read N times (once per row of C). That is 2N³ global memory reads for N³ multiply-add operations — an arithmetic intensity of just 0.5 FLOP/byte.

Complete Compilable Program

naive_matmul.cu — compile: nvcc -o naive_matmul naive_matmul.cu
#include <stdio.h>
#include <stdlib.h>
#include <cuda_runtime.h>
#include <math.h>

// Error-checking macro
#define CUDA_CHECK(call) do { \
    cudaError_t err = call; \
    if (err != cudaSuccess) { \
        fprintf(stderr, "CUDA error at %s:%d: %s\n", \
                __FILE__, __LINE__, cudaGetErrorString(err)); \
        exit(EXIT_FAILURE); \
    } \
} while (0)

// ── Naive matrix multiply kernel ──
// C = A * B, where A is (M x K), B is (K x N), C is (M x N)
__global__ void matmulNaive(const float *A, const float *B, float *C,
                           int M, int K, int N) {
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;

    if (row < M && col < N) {
        float sum = 0.0f;
        for (int i = 0; i < K; i++) {
            sum += A[row * K + i] * B[i * N + col];
        }
        C[row * N + col] = sum;
    }
}

// ── Host helper: initialise matrix with random values ──
void initMatrix(float *mat, int rows, int cols) {
    for (int i = 0; i < rows * cols; i++)
        mat[i] = (float)(rand()) / RAND_MAX;
}

// ── Host reference multiply for verification ──
void matmulCPU(const float *A, const float *B, float *C,
               int M, int K, int N) {
    for (int i = 0; i < M; i++)
        for (int j = 0; j < N; j++) {
            float sum = 0.0f;
            for (int k = 0; k < K; k++)
                sum += A[i * K + k] * B[k * N + j];
            C[i * N + j] = sum;
        }
}

int main(void) {
    // Matrix dimensions: A(M x K) * B(K x N) = C(M x N)
    const int M = 1024, K = 1024, N = 1024;
    size_t sizeA = M * K * sizeof(float);
    size_t sizeB = K * N * sizeof(float);
    size_t sizeC = M * N * sizeof(float);

    // Allocate host memory
    float *h_A = (float *)malloc(sizeA);
    float *h_B = (float *)malloc(sizeB);
    float *h_C = (float *)malloc(sizeC);
    float *h_ref = (float *)malloc(sizeC);

    srand(42);
    initMatrix(h_A, M, K);
    initMatrix(h_B, K, N);

    // Allocate device memory
    float *d_A, *d_B, *d_C;
    CUDA_CHECK(cudaMalloc(&d_A, sizeA));
    CUDA_CHECK(cudaMalloc(&d_B, sizeB));
    CUDA_CHECK(cudaMalloc(&d_C, sizeC));

    // Copy inputs to device
    CUDA_CHECK(cudaMemcpy(d_A, h_A, sizeA, cudaMemcpyHostToDevice));
    CUDA_CHECK(cudaMemcpy(d_B, h_B, sizeB, cudaMemcpyHostToDevice));

    // Launch kernel: 16x16 threads per block
    dim3 blockDim(16, 16);
    dim3 gridDim((N + blockDim.x - 1) / blockDim.x,
                 (M + blockDim.y - 1) / blockDim.y);

    // Warm up
    matmulNaive<<<gridDim, blockDim>>>(d_A, d_B, d_C, M, K, N);
    CUDA_CHECK(cudaDeviceSynchronize());

    // Timed run
    cudaEvent_t start, stop;
    CUDA_CHECK(cudaEventCreate(&start));
    CUDA_CHECK(cudaEventCreate(&stop));
    CUDA_CHECK(cudaEventRecord(start));

    matmulNaive<<<gridDim, blockDim>>>(d_A, d_B, d_C, M, K, N);

    CUDA_CHECK(cudaEventRecord(stop));
    CUDA_CHECK(cudaEventSynchronize(stop));
    float ms = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&ms, start, stop));

    // Copy result back
    CUDA_CHECK(cudaMemcpy(h_C, d_C, sizeC, cudaMemcpyDeviceToHost));

    // Verify against CPU
    matmulCPU(h_A, h_B, h_ref, M, K, N);
    float maxErr = 0.0f;
    for (int i = 0; i < M * N; i++)
        maxErr = fmaxf(maxErr, fabsf(h_C[i] - h_ref[i]));

    printf("Naive MatMul (%dx%d x %dx%d)\n", M, K, K, N);
    printf("  Time:      %.3f ms\n", ms);
    printf("  GFLOPS:    %.1f\n",
           2.0 * M * N * K / (ms * 1e6));
    printf("  Max error: %e\n", maxErr);

    // Cleanup
    cudaEventDestroy(start);
    cudaEventDestroy(stop);
    cudaFree(d_A); cudaFree(d_B); cudaFree(d_C);
    free(h_A); free(h_B); free(h_C); free(h_ref);
    return 0;
}
Compile & Run

nvcc -o naive_matmul naive_matmul.cu && ./naive_matmul — the program times the kernel, computes GFLOPS, and verifies against a CPU reference.

03

Performance Analysis of Naive

Let's quantify why the naive kernel is slow. The bottleneck is not compute — it's memory bandwidth.

Arithmetic Intensity

For an N×N matrix multiply, each output element requires N multiply-adds (2N FLOP) and reads 2N floats (8N bytes) from global memory.

arithmetic intensity calculation
// Per output element:
FLOPs          = 2 * N        // N multiplies + N adds
Bytes loaded   = 2 * N * 4    // N floats from A row + N floats from B col
Arith intensity = 2N / (8N) = 0.25 FLOP/byte

// For N = 1024:
Total FLOPs  = 2 * 1024^3     = 2.15 billion
Total bytes  = 2 * 1024^3 * 4 = 8.59 GB  // with redundant reads!

The Roofline Model

A modern GPU (e.g., RTX 3090) has ~936 GB/s memory bandwidth and ~36 TFLOPS FP32. The crossover point (ridge point) is at ~38 FLOP/byte. Our naive kernel at 0.25 FLOP/byte is firmly memory-bound — operating at less than 1% of the compute roofline.

Peak Compute: 36 TFLOPS
↓
Ridge Point: 38.5 FLOP/byte
↓
Naive MatMul: 0.25 FLOP/byte — 150× below ridge!

Typical Naive Kernel Metrics (1024×1024)

Metric Value Notes
Kernel time ~5–15 ms Varies by GPU generation
Effective GFLOPS ~150–400 Far below peak capability
Global loads 2N³ = 2.1 billion Massive redundancy
Arithmetic intensity 0.25 FLOP/byte Memory-bound regime
Data reuse None Every thread reads independently
The Fix

We need to increase data reuse. If a block of threads cooperatively loads a chunk of A and B into fast shared memory, each element is fetched from global memory once and reused many times. This is the core idea behind tiling.

04

Tiled Multiplication — Concept

Instead of each thread independently streaming through global memory, we divide the computation into tiles. A tile is a small square sub-matrix (typically 16×16 or 32×32) that fits in shared memory.

How Tiling Works

Load tile from A
→
Load tile from B
→
__syncthreads()
→
Compute partial dot products
→
__syncthreads()
→
Repeat for next tile

Tile Loading Diagram

To compute one TILE_SIZE×TILE_SIZE block of C, we sweep across K in steps of TILE_SIZE, loading one tile from A's row-band and one from B's column-band each step.

Matrix A (M × K)
a
a
.
.
.
.
.
.
a
a
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
Tile from row-band of A
×
Matrix B (K × N)
b
b
.
.
.
.
.
.
b
b
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
Tile from col-band of B
=
Matrix C (M × N)
c
c
.
.
.
.
.
.
c
c
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
Output tile of C (accumulated)

Shared Memory Tile Flow

One Tile Iteration (step t)
Global Memory
A tile
B tile
→
Shared Memory
As[ty][tx]
Bs[ty][tx]
__syncthreads()
→
Registers
sum += As[ty][i] * Bs[i][tx]
__syncthreads()

Reuse Factor

With a tile size of T×T, each element loaded into shared memory is used by T threads. Global memory traffic drops from 2N³ to 2N³/T reads. For T=16, that's a 16× reduction in global memory bandwidth demand.

Tile Size Shared Mem per Block Global Load Reduction Arith. Intensity
No tiling 0 bytes 1× (baseline) 0.25 FLOP/byte
16 × 16 2 KB 16× 4.0 FLOP/byte
32 × 32 8 KB 32× 8.0 FLOP/byte
05

Tiled Implementation

The tiled kernel uses __shared__ memory arrays to cache sub-matrices, __syncthreads() barriers to coordinate loading and computation, and boundary checks to handle matrices that aren't multiples of the tile size.

Complete Compilable Program

tiled_matmul.cu — compile: nvcc -o tiled_matmul tiled_matmul.cu
#include <stdio.h>
#include <stdlib.h>
#include <cuda_runtime.h>
#include <math.h>

#define TILE_SIZE 16

// Error-checking macro
#define CUDA_CHECK(call) do { \
    cudaError_t err = call; \
    if (err != cudaSuccess) { \
        fprintf(stderr, "CUDA error at %s:%d: %s\n", \
                __FILE__, __LINE__, cudaGetErrorString(err)); \
        exit(EXIT_FAILURE); \
    } \
} while (0)

// ── Tiled matrix multiply kernel ──
// C = A * B, where A is (M x K), B is (K x N), C is (M x N)
__global__ void matmulTiled(const float *A, const float *B, float *C,
                           int M, int K, int N) {
    // Shared memory tiles for A and B
    __shared__ float As[TILE_SIZE][TILE_SIZE];
    __shared__ float Bs[TILE_SIZE][TILE_SIZE];

    int tx = threadIdx.x, ty = threadIdx.y;
    int row = blockIdx.y * TILE_SIZE + ty;
    int col = blockIdx.x * TILE_SIZE + tx;

    float sum = 0.0f;

    // Sweep across tiles along the K dimension
    int numTiles = (K + TILE_SIZE - 1) / TILE_SIZE;
    for (int t = 0; t < numTiles; t++) {

        // ── Load tile from A into shared memory ──
        int aCol = t * TILE_SIZE + tx;
        if (row < M && aCol < K)
            As[ty][tx] = A[row * K + aCol];
        else
            As[ty][tx] = 0.0f;

        // ── Load tile from B into shared memory ──
        int bRow = t * TILE_SIZE + ty;
        if (bRow < K && col < N)
            Bs[ty][tx] = B[bRow * N + col];
        else
            Bs[ty][tx] = 0.0f;

        // Wait for all threads to finish loading
        __syncthreads();

        // ── Compute partial dot product from this tile ──
        for (int i = 0; i < TILE_SIZE; i++)
            sum += As[ty][i] * Bs[i][tx];

        // Wait before loading next tile
        __syncthreads();
    }

    // ── Write result ──
    if (row < M && col < N)
        C[row * N + col] = sum;
}

// ── Host helper: initialise matrix with random values ──
void initMatrix(float *mat, int rows, int cols) {
    for (int i = 0; i < rows * cols; i++)
        mat[i] = (float)(rand()) / RAND_MAX;
}

// ── Host reference multiply for verification ──
void matmulCPU(const float *A, const float *B, float *C,
               int M, int K, int N) {
    for (int i = 0; i < M; i++)
        for (int j = 0; j < N; j++) {
            float sum = 0.0f;
            for (int k = 0; k < K; k++)
                sum += A[i * K + k] * B[k * N + j];
            C[i * N + j] = sum;
        }
}

int main(void) {
    // Matrix dimensions: A(M x K) * B(K x N) = C(M x N)
    const int M = 1024, K = 1024, N = 1024;
    size_t sizeA = M * K * sizeof(float);
    size_t sizeB = K * N * sizeof(float);
    size_t sizeC = M * N * sizeof(float);

    // Allocate host memory
    float *h_A = (float *)malloc(sizeA);
    float *h_B = (float *)malloc(sizeB);
    float *h_C = (float *)malloc(sizeC);
    float *h_ref = (float *)malloc(sizeC);

    srand(42);
    initMatrix(h_A, M, K);
    initMatrix(h_B, K, N);

    // Allocate device memory
    float *d_A, *d_B, *d_C;
    CUDA_CHECK(cudaMalloc(&d_A, sizeA));
    CUDA_CHECK(cudaMalloc(&d_B, sizeB));
    CUDA_CHECK(cudaMalloc(&d_C, sizeC));

    // Copy inputs to device
    CUDA_CHECK(cudaMemcpy(d_A, h_A, sizeA, cudaMemcpyHostToDevice));
    CUDA_CHECK(cudaMemcpy(d_B, h_B, sizeB, cudaMemcpyHostToDevice));

    // Launch kernel: TILE_SIZE x TILE_SIZE threads per block
    dim3 blockDim(TILE_SIZE, TILE_SIZE);
    dim3 gridDim((N + TILE_SIZE - 1) / TILE_SIZE,
                 (M + TILE_SIZE - 1) / TILE_SIZE);

    // Warm up
    matmulTiled<<<gridDim, blockDim>>>(d_A, d_B, d_C, M, K, N);
    CUDA_CHECK(cudaDeviceSynchronize());

    // Timed run
    cudaEvent_t start, stop;
    CUDA_CHECK(cudaEventCreate(&start));
    CUDA_CHECK(cudaEventCreate(&stop));
    CUDA_CHECK(cudaEventRecord(start));

    matmulTiled<<<gridDim, blockDim>>>(d_A, d_B, d_C, M, K, N);

    CUDA_CHECK(cudaEventRecord(stop));
    CUDA_CHECK(cudaEventSynchronize(stop));
    float ms = 0.0f;
    CUDA_CHECK(cudaEventElapsedTime(&ms, start, stop));

    // Copy result back
    CUDA_CHECK(cudaMemcpy(h_C, d_C, sizeC, cudaMemcpyDeviceToHost));

    // Verify against CPU
    matmulCPU(h_A, h_B, h_ref, M, K, N);
    float maxErr = 0.0f;
    for (int i = 0; i < M * N; i++)
        maxErr = fmaxf(maxErr, fabsf(h_C[i] - h_ref[i]));

    printf("Tiled MatMul (%dx%d x %dx%d), TILE_SIZE=%d\n",
           M, K, K, N, TILE_SIZE);
    printf("  Time:      %.3f ms\n", ms);
    printf("  GFLOPS:    %.1f\n",
           2.0 * M * N * K / (ms * 1e6));
    printf("  Max error: %e\n", maxErr);

    // Cleanup
    cudaEventDestroy(start);
    cudaEventDestroy(stop);
    cudaFree(d_A); cudaFree(d_B); cudaFree(d_C);
    free(h_A); free(h_B); free(h_C); free(h_ref);
    return 0;
}

Key Points in the Code

Compile & Run

nvcc -o tiled_matmul tiled_matmul.cu && ./tiled_matmul — compare the GFLOPS to the naive version. On most GPUs you'll see a significant speedup.

06

Performance Comparison

The following benchmarks were collected on an NVIDIA RTX 3090 (GA102, Ampere). Your results will vary but the relative speedup should be consistent across GPU generations.

Naive vs Tiled (TILE_SIZE = 16)

Matrix Size Naive (ms) Tiled (ms) Speedup Naive GFLOPS Tiled GFLOPS
256 × 256 0.12 0.06 2.0× 280 560
512 × 512 0.85 0.22 3.9× 316 1,217
1024 × 1024 6.2 1.1 5.6× 347 1,954
2048 × 2048 48.5 7.8 6.2× 355 2,203
4096 × 4096 385.0 58.2 6.6× 357 2,362

Why the Speedup Grows with Size

Larger matrices have more tiles, which means better GPU occupancy and more opportunity for the memory hierarchy to hide latency. The tiled kernel's advantage widens as the compute-to-overhead ratio improves.

Naive Kernel

  • ~350 GFLOPS regardless of size
  • Bottlenecked on global memory bandwidth
  • No data reuse between threads

Tiled Kernel

  • ~2,000+ GFLOPS at large sizes
  • 16× reduction in global memory traffic
  • Each shared mem element reused by 16 threads

Bandwidth Utilisation

Kernel Global Mem Reads (1024×1024) Effective BW % of Peak (936 GB/s)
Naive ~8.6 GB ~700 GB/s ~75%
Tiled (T=16) ~0.54 GB ~490 GB/s ~52%
Key Takeaway

The naive kernel saturates memory bandwidth but wastes it on redundant loads. The tiled kernel uses less total bandwidth by reusing data, freeing the compute units to actually do useful work.

07

Further Optimisation Ideas

Our tiled kernel is a major improvement, but production GEMM libraries like cuBLAS go much further. Here are the main techniques they employ.

Optimisation Hierarchy

Naive global memory — 0.25 FLOP/byte
↓
Shared memory tiling — 4.0 FLOP/byte
↓
Register tiling — even higher reuse
↓
Double buffering — overlap load & compute
↓
cuBLAS / CUTLASS — near-peak performance

Register Tiling

Each thread computes a small sub-tile (e.g., 4×4 elements) rather than a single element. This increases register-level reuse: each shared memory load feeds multiple FMA operations.

  • Reduces shared memory reads per FLOP
  • Better instruction-level parallelism
  • Typical sub-tile: 4×4 to 8×8 per thread

Double Buffering

Use two shared memory buffers: compute on buffer 0 while loading the next tile into buffer 1. This hides global memory latency behind computation.

  • Overlaps memory latency with compute
  • Doubles shared memory usage
  • Requires careful synchronisation

Additional Techniques

cuBLAS as the Gold Standard

Using cuBLAS for comparison
#include <cublas_v2.h>

cublasHandle_t handle;
cublasCreate(&handle);

float alpha = 1.0f, beta = 0.0f;
// C = alpha * A * B + beta * C
cublasSgemm(handle, CUBLAS_OP_N, CUBLAS_OP_N,
            N, M, K, &alpha,
            d_B, N, d_A, K, &beta, d_C, N);

cublasDestroy(handle);
Reality Check

cuBLAS on an RTX 3090 achieves ~25+ TFLOPS for large FP32 GEMM — roughly 10× faster than our tiled kernel. Writing your own kernel is great for learning; for production code, use the vendor library.

08

Exercises

Hands-on practice to solidify your understanding of tiled matrix multiplication and GPU performance analysis.

Exercise 1: Non-Square Matrices

Task

Modify the tiled kernel to multiply non-square matrices: A(512 × 768) × B(768 × 1024) = C(512 × 1024). Verify the result against the CPU reference. The boundary handling in our tiled kernel already supports this — but make sure you understand why by tracing through the boundary conditions.

Exercise 2: Tile Size Sweep

Task

Run the tiled kernel with TILE_SIZE = 8, 16, and 32 on a 2048×2048 matrix. Record the execution time and GFLOPS for each. Which tile size is fastest on your GPU? What limits you from going to TILE_SIZE = 64?

Exercise 3: Profile with Nsight Compute

Task

Profile both the naive and tiled kernels using NVIDIA Nsight Compute:

Nsight Compute profiling commands
# Profile naive kernel
ncu --set full -o naive_profile ./naive_matmul

# Profile tiled kernel
ncu --set full -o tiled_profile ./tiled_matmul

# Compare specific metrics
ncu --metrics sm__throughput_pct,\
dram__bytes_read.sum,\
l1tex__t_bytes_pipe_lsu_mem_global_op_ld.sum \
./naive_matmul

Compare these metrics between the two kernels:

Exercise 4: Register Tiling (Advanced)

Challenge

Modify the tiled kernel so each thread computes a 2×2 sub-tile of C instead of a single element. This means each block computes a (2×TILE_SIZE) × (2×TILE_SIZE) output region. Measure the improvement. Hint: you'll need to load the same shared memory tile but accumulate into 4 register variables.

09

Summary & Next Steps

What We Covered

Naive: 1 thread = 1 element, global mem only
→
Tiled: shared memory, 16× less bandwidth
→
5–7× speedup

Key Takeaways

Concepts Introduced

Concept Description
__shared__ Declares shared memory visible to all threads in a block
__syncthreads() Block-level barrier — all threads must reach it before any proceed
Tiling Decomposing a problem into sub-problems that fit in fast on-chip memory
Arithmetic Intensity FLOP per byte of memory traffic — determines memory-bound vs compute-bound
Roofline Model Framework for understanding performance limits based on compute and memory bandwidth

Next Tutorial

Up Next — Tutorial 06

Synchronisation & Atomic Operations — explore thread synchronisation beyond __syncthreads(): atomic operations, memory fences, cooperative groups, and patterns for reductions and histograms.