3. 循环分块(Loop Tiling)
任务目标
本案例针对矩阵乘法 C = A × B,通过循环分块(Loop Tiling)将计算划分为适合 L1 缓存大小的子块,减少缓存行替换,提升数据局部性。你需要对比朴素三重循环与分块实现的缓存行为差异,并验证分块后执行时间下降。
环境与依赖
- 编译器:gcc(支持 -O2 优化)
- 可选:
perf stat或time命令用于测量 - 无第三方库依赖
核心实现
以下代码包含朴素版本与分块版本,矩阵大小 N=512,分块大小 BLOCK=32(适配典型 32KB L1 缓存,每个 double 8 字节,32×32×8×3 ≈ 24KB)。
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define N 512
#define BLOCK 32
void matmul_naive(double *A, double *B, double *C) {
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++) {
double sum = 0.0;
for (int k = 0; k < N; k++)
sum += A[i * N + k] * B[k * N + j];
C[i * N + j] = sum;
}
}
void matmul_tiled(double *A, double *B, double *C) {
for (int i0 = 0; i0 < N; i0 += BLOCK)
for (int j0 = 0; j0 < N; j0 += BLOCK)
for (int k0 = 0; k0 < N; k0 += BLOCK)
for (int i = i0; i < i0 + BLOCK; i++)
for (int j = j0; j < j0 + BLOCK; j++) {
double sum = C[i * N + j];
for (int k = k0; k < k0 + BLOCK; k++)
sum += A[i * N + k] * B[k * N + j];
C[i * N + j] = sum;
}
}
void init(double *M) {
for (int i = 0; i < N * N; i++)
M[i] = (double)(rand() % 100) / 10.0;
}
double now_ms() {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec * 1000.0 + ts.tv_nsec / 1e6;
}
int main() {
double *A = malloc(N * N * sizeof(double));
double *B = malloc(N * N * sizeof(double));
double *C1 = calloc(N * N, sizeof(double));
double *C2 = calloc(N * N, sizeof(double));
srand(42);
init(A); init(B);
double t0 = now_ms();
matmul_naive(A, B, C1);
double t1 = now_ms();
matmul_tiled(A, B, C2);
double t2 = now_ms();
// 正确性校验
double max_diff = 0.0;
for (int i = 0; i < N * N; i++) {
double d = C1[i] - C2[i];
if (d < 0) d = -d;
if (d > max_diff) max_diff = d;
}
printf("Naive: %.2f ms\n", t1 - t0);
printf("Tiled: %.2f ms\n", t2 - t1);
printf("Max diff: %e\n", max_diff);
free(A); free(B); free(C1); free(C2);
return 0;
}
编译与运行
gcc -O2 -o matmul matmul.c -lm
./matmul
验证方法
- 正确性:程序输出
Max diff应小于 1e-10(浮点累加顺序差异导致微小误差),表明分块结果与朴素版本一致。 - 性能对比:观察
Tiled耗时是否明显低于Naive。在 N=512、BLOCK=32 时,分块版本通常快 2~4 倍(具体取决于 CPU 缓存大小)。 - 缓存行为(可选):运行
perf stat -e cache-misses ./matmul,对比两次调用的 cache-misses 计数,分块版本应显著更低。
关键说明
分块的核心在于将三重循环的 k 维度也切块,使 A 的子块(32×32)和 B 的子块(32×32)能同时驻留于 L1 缓存,避免反复从内存加载。调整 BLOCK 值可观察性能变化——过小无法充分利用缓存行,过大则导致缓存溢出。若你的 CPU L1 缓存不是 32KB,可按 BLOCK = sqrt(L1_cache_size / 3 / 8) 估算。