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.
Peak throughput on NVIDIA Tensor Cores (FP16, WMMA API), ~94% of theoretical peak
Over scalar CPU baseline (0.25 → 3,749 GFLOPS)
From cache-aware Blocked-Interchange + AVX2 SIMD (0.25 → 10.11 GFLOPS)
GPU shared-memory tiling over naïve global-memory CUDA kernel
0.25 GFLOPS
Implemented a naive scalar CPU matrix multiplication, then optimized the loop ordering to respect CPU cache lines.
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];
10.11 GFLOPS (39.5× over baseline)
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.
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);
}
38.35 GFLOPS
Distributed the blocked AVX2 workload across multiple CPU cores using OpenMP.
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);
}
}
}
162 → 3,749 GFLOPS
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.
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);
| 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.
std::chrono::high_resolution_clock for CPU, cudaEventRecord for GPU (kernel execution time only, excluding host-to-device data transfer)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.
Investigating structured sparsity (2:4) for efficient inference execution, and implementing Bayesian auto-tuning for dynamic hardware-specific tile sizing.