FULL TECHNICAL CASE STUDY

From 0.25 to 3,749 GFLOPS —
Optimising GEMM Kernels Across the Memory Hierarchy

A four-stage empirical study of General Matrix Multiply performance — scalar CPU baseline → cache-aware blocking + SIMD → OpenMP multi-core → NVIDIA Tensor Core WMMA — conducted as a research internship at IIT Palakkad.

Institution: IIT Palakkad  ·  Advisor: Prof. Unnikrishnan Cheramangalath  ·  Hardware: Intel i5-12th Gen + NVIDIA GPU (Tensor Cores)  ·  Duration: Dec 2025 – Jan 2026
3,749
GFLOPS

Peak throughput on NVIDIA Tensor Cores (FP16, WMMA API), ~94% of theoretical peak

14,640×
TOTAL SPEEDUP

Over scalar CPU baseline (0.25 → 3,749 GFLOPS)

39.5×
SINGLE-CORE CPU GAIN

From cache-aware Blocked-Interchange + AVX2 SIMD (0.25 → 10.11 GFLOPS)

23×
GPU TILING GAIN

GPU shared-memory tiling over naïve global-memory CUDA kernel

Performance Scaling.

LOG SCALE VISUALIZATION (GFLOPS)
Scalar Baseline
0.25
Blocked + AVX2
10.11
OpenMP 8-Core
38.35
CUDA Shared-Mem
~162
WMMA Tensor Cores
3,749
PHASE 1

Scalar Baseline with Cache-Aware Loop Ordering

0.25 GFLOPS

What was done

Implemented a naive scalar CPU matrix multiplication, then optimized the loop ordering to respect CPU cache lines.

The Key Technical Decision

Naïve i–j–k order causes a cache miss on matrix B on every inner iteration because B[k][p] strides across rows. Reordering the loops to i–k–j fixes this: the inner j-loop accesses B[k][j] sequentially, allowing it to stay within the L1 cache line.

// Naïve i-j-k (Bad cache locality on B)
for (int i = 0; i < m; i++)
  for (int j = 0; j < p; j++)
    for (int k = 0; k < n; k++)
      C[i*p+j] += A[i*n+k] * B[k*p+j];

// Cache-aware i-k-j (Good cache locality)
for (int i = 0; i < m; i++)
  for (int k = 0; k < n; k++)
    for (int j = 0; j < p; j++)
      C[i*p+j] += A[i*n+k] * B[k*p+j];
Key Observation: Cache-aware loop reordering alone delivers 4–6× speedup before introducing any form of parallelism.
PHASE 2

Blocked-Interchange Tiling + AVX2 SIMD

10.11 GFLOPS (39.5× over baseline)

What was done

Introduced multi-level loop tiling (blocking) to keep active working sets entirely within the L1 cache, combined with Advanced Vector Extensions (AVX2) for Single Instruction Multiple Data (SIMD) execution.

The Key Technical Decision

Tiles were sized explicitly to fit in L1 data cache. For float32, T=64 was chosen: 3 matrices × (64×64) × 4 bytes = 48 KB, precisely matching the Intel P-core L1 cache size. The tile size was empirically swept over T ∈ {32, 64, 128, 256}. Under AVX2, the _mm256_fmadd_ps intrinsic executes 8 float Fused Multiply-Adds (FMAs) per cycle, with a horizontal reduction performed only once per tile row.

// AVX2 SIMD Core Computation
__m256 a_val = _mm256_set1_ps(A[i*n+k]);
for (int j = 0; j < T; j+=8) {
  __m256 b_vec = _mm256_loadu_ps(&B[k*p+j]);
  __m256 c_vec = _mm256_loadu_ps(&C[i*p+j]);
  c_vec = _mm256_fmadd_ps(a_val, b_vec, c_vec);
  _mm256_storeu_ps(&C[i*p+j], c_vec);
}
Key Observation: This architectural optimization brings the implementation into the compute-bound regime for the first time.
PHASE 3

OpenMP Multi-Core Parallelism

38.35 GFLOPS

What was done

Distributed the blocked AVX2 workload across multiple CPU cores using OpenMP.

The Key Technical Decision

The outer loop was parallelised using #pragma omp parallel for with static scheduling. Static scheduling was chosen over dynamic because all tile rows have identical computational cost; dynamic scheduling overhead would be pure waste. Due to the Intel i5-12th Gen P-core/E-core asymmetry, work was pinned to P-cores only.

False sharing investigation: Early runs showed sub-linear scaling. Performance profiling revealed cache-line invalidation traffic from adjacent C tiles being updated by different cores. Aligning the tiles to 64-byte boundaries resolved the false sharing hazard.

// Parallelizing the outer tile loops
#pragma omp parallel for schedule(static)
for (int ii = 0; ii < m; ii += T) {
  for (int kk = 0; kk < n; kk += T) {
    for (int jj = 0; jj < p; jj += T) {
      compute_tile_avx2(ii, kk, jj, T);
    }
  }
}
Key Observation: Scalability yielded a 3.8× speedup over a single core (not an ideal 8×) primarily due to shared L3 bandwidth contention when all cores attempt simultaneous memory fetches.
PHASE 4

CUDA: Shared-Memory Tiling & WMMA Tensor Cores

162 → 3,749 GFLOPS

What was done

Transitioned execution to the NVIDIA GPU. First, implemented shared-memory tiling, then escalated to utilize hardware Tensor Cores via the WMMA (Warp Matrix Multiply Accumulate) API.

The Key Technical Decision

Shared-memory tiling: Thread blocks load 16×16 tiles of matrices A and B from global DRAM into fast SRAM (shared memory) to compute partial products. This reduces global memory accesses from O(n³) to O(n³/T), delivering a 23× speedup over the naïve global-memory CUDA kernel.

WMMA: Replaced thread-level scalar math with warp-level 16×16×16 FMA fragments via the nvcuda::wmma API. D = A·B + C is executed in a single warp-synchronous operation at FP16 precision. Required defining correct fragment types (matrix_a, matrix_b, accumulator), managing shared-memory double-buffering to overlap compute with data loading, and rigorous synchronization barriers. The final throughput sits ~6% below theoretical peak due to shared-memory bank conflicts and boundary tile handling.

// WMMA Tensor Core Fragment Math
using namespace nvcuda;
wmma::fragment16, 16, 16, half, wmma::row_major> a_frag;
wmma::fragment16, 16, 16, half, wmma::col_major> b_frag;
wmma::fragment16, 16, 16, float> c_frag;

wmma::fill_fragment(c_frag, 0.0f);

// Load from shared memory
wmma::load_matrix_sync(a_frag, shared_A, 16);
wmma::load_matrix_sync(b_frag, shared_B, 16);

// Hardware FMA
wmma::mma_sync(c_frag, a_frag, b_frag, c_frag);

// Store back to global memory
wmma::store_matrix_sync(&C[idx], c_frag, p, wmma::mem_row_major);

Roofline Analysis.

Implementation Throughput Bound Limiting Factor
Scalar baseline 0.25 GFLOPS Memory-bound Cache misses on B (i–j–k order)
Blocked + AVX2 10.11 GFLOPS Compute-bound Single-core FMA throughput
OpenMP 8-core 38.35 GFLOPS Bandwidth-bound Shared L3 contention at scale
CUDA shared-mem ~162 GFLOPS Compute-bound CUDA core FP32 throughput
WMMA Tensor Cores 3,749 GFLOPS Near-peak ~6% loss: bank conflicts + boundary tiles

The Roofline Model establishes that attainable performance is bounded by min(Peak Compute, Bandwidth × Arithmetic Intensity). For General Matrix Multiply (GEMM) at n=2048, the arithmetic intensity is extremely high (approximately n/3 ≈ 682 FLOPs/byte for float32). Because this intensity vastly exceeds the compute-to-bandwidth ratio of modern hardware, a properly optimized GEMM implementation should almost always be strictly compute-bound rather than memory-bound.

Benchmarking Methodology.

  • Hardware: Intel i5-12th Gen (P-core L1: 48 KB, L2: 1.25 MB per core), NVIDIA GPU (Tensor Core-capable), CUDA 12.x
  • Timing: std::chrono::high_resolution_clock for CPU, cudaEventRecord for GPU (kernel execution time only, excluding host-to-device data transfer)
  • Protocol: Median of 50 warm runs, with 5 initial cold runs discarded to eliminate JIT and caching artifacts
  • Matrix sizes swept: 64×64 up to 4096×4096 in powers of two
  • Headline numbers derived at: 2048×2048, utilizing float32 (CPU routines) or float16 (GPU Tensor Core routines)
  • Reproducibility: Fixed random seed 42 used across all data initializations
  • Source: github.com/anzinmhd/cpu-gpu-matmul-benchmark (MIT license)

Final Findings.

BROADER IMPLICATION:

The 14,640× end-to-end speedup observed is primarily a software gap, not purely a hardware gap. Unoptimized software leaves orders of magnitude of hardware potential completely unused.

OPEN QUESTIONS & FUTURE WORK:

Investigating structured sparsity (2:4) for efficient inference execution, and implementing Bayesian auto-tuning for dynamic hardware-specific tile sizing.

Explore the Code

← OVERVIEW PAGE GITHUB REPOSITORY → PORTFOLIO HOME