Complete Mathematics of the Given Matrix Multiplication Code¶
1. Problem Setup¶
Given three matrices:
The operation computed is:
For the main() function specifically:
So \(A, B, C \in \mathbb{R}^{2000 \times 2000}\) (with \(A, B\) being \(2000 \times 2000\) as well since \(m=k=n=2000\)).
2. Element-wise Definition¶
Each element of the product is defined as:
for all \(0 \le i < m\) and \(0 \le j < n\).
This corresponds directly to the inner loop:
3. Row-Major vs Column-Major Memory Layout¶
3.1 Row-Major Layout (used by matmul_row)¶
In row-major order, element \(A_{ij}\) is stored at linear address:
This matches the macro:
Memory access pattern in the triple loop \((i, j, p)\):
| Loop variable | Access | Stride |
|---|---|---|
| \(p\) (innermost) | \(A_{\text{row}}(i,p)\) | 1 (contiguous) |
| \(p\) (innermost) | \(B_{\text{row}}(p,j)\) | \(\text{ldb} = k\) (non-contiguous) |
| \(j\) | \(C_{\text{row}}(i,j)\) | 1 (contiguous) |
So in matmul_row, A is accessed contiguously, but B is accessed with stride \(k\) — poor cache locality on B.
3.2 Column-Major Layout (used by matmul_col)¶
In column-major order (Fortran/BLAS style), element \(A_{ij}\) is stored at:
This matches the macro:
Memory access pattern in the triple loop \((i, j, p)\):
| Loop variable | Access | Stride |
|---|---|---|
| \(p\) (innermost) | \(A_{\text{col}}(i,p)\) | \(\text{lda} = m\) (non-contiguous) |
| \(p\) (innermost) | \(B_{\text{col}}(p,j)\) | 1 (contiguous) |
| \(j\) | \(C_{\text{col}}(i,j)\) | \(\text{ldc} = m\) (non-contiguous) |
So in matmul_col, B is accessed contiguously, but A and C are strided.
4. Total Arithmetic Work¶
Each \(C_{ij}\) requires \(k\) multiplications and \(k\) additions:
Total over all \(m \times n\) elements:
For \(m = n = k = 2000\):
This matches the code's gflops = 2.0 * m*n*k * 1.0e-09.
Performance metric:
5. Memory Traffic (Cache Analysis)¶
For a naive triple loop, the theoretical minimum data movement is \(O(mn + mk + nk)\), but the naive implementation incurs much more.
5.1 Naive Row-Major Cost¶
For each of the \(m \cdot n\) output elements, the inner loop over \(p\) reads \(k\) elements of a row of A and \(k\) elements of a column of B.
- Reads of A: \(m \cdot n \cdot k\) (each row of A re-read \(n\) times)
- Reads of B: \(m \cdot n \cdot k\) (each column of B re-read \(m\) times, with cache misses due to stride \(k\))
Total memory traffic:
This is the same order as the arithmetic work, giving an arithmetic intensity of:
This is memory-bound on modern hardware — the CPU spends most of its time waiting for cache misses rather than computing.
5.2 Cache Block Considerations¶
Let \(B\) be the cache block size (in doubles). For matmul_row, accessing \(B_{\text{row}}(p,j)\) with stride \(k\) pulls in a full cache line for each element used:
6. Loop Nest and Accumulator Form¶
The code's loop order is \((i, j, p)\):
This is the "dot product" formulation, where each output element is computed independently as a single dot product of a row of A with a column of B.
An equivalent but more cache-friendly axpy / rank-1 update form (loop order \(i, p, j\)) would be:
which makes the innermost loop over \(j\) contiguous in row-major layout. The given code does not use this, which is why both versions perform poorly.
7. Summary Table¶
| Aspect | matmul_row |
matmul_col |
|---|---|---|
| Layout | Row-major | Column-major |
| Inner loop stride (A) | 1 (good) | \(m\) (bad) |
| Inner loop stride (B) | \(k\) (bad) | 1 (good) |
| Inner loop stride (C) | 1 (good) | \(m\) (bad) |
| FLOPs | \(2mnk\) | \(2mnk\) |
| Arithmetic intensity | \(O(1)\) | \(O(1)\) |
| Bottleneck | Memory bandwidth / cache misses | Memory bandwidth / cache misses |
Bottom line: both versions do the same \(2mnk\) arithmetic work, but the naive \((i,j,p)\) loop order makes them memory-bound, with the row-major version generally being faster on x86 because the contiguous A access plus contiguous C write partially offsets the strided B reads.
Want me to show the math for a blocked/tiled version that turns this into a compute-bound kernel?