土法炼钢兴趣小组的算法知识备份

缓存无关算法:让硬件替你优化

目录

现代计算机的性能瓶颈早已从计算转移到了内存访问。一次 L1 缓存命中耗时约 1ns,而一次主存访问则要 100ns——差了两个数量级。当数据规模达到数十 GB,磁盘 I/O 又要慢上 10 万倍。在这样的硬件现实下,算法的计算复杂度有时候甚至没有它的 I/O 复杂度重要。

传统的优化方式是”缓存感知”(cache-aware):程序员需要精确知道缓存大小 M 和缓存行宽度 B,然后针对这两个参数精心设计数据布局。B-tree 就是经典例子——分支因子直接取决于磁盘块大小。

但有一类算法完全不需要知道 M 和 B,却能在所有层级的缓存上同时达到最优性能。这就是缓存无关(cache-oblivious)算法。

一、外部存储模型

1.1 模型定义

外部存储模型(External Memory Model)由 Aggarwal 和 Vitter 在 1988 年提出,将存储简化为两层:

+---------------------------+
|     内部存储(快速)        |    容量: M 个元素
|     可以直接计算           |
+---------------------------+
            |
            | 每次传输 B 个元素(一个"块")
            v
+---------------------------+
|     外部存储(慢速)        |    容量: 无限
|     不能直接计算           |
+---------------------------+

关键参数:

性能度量:I/O 次数——块在两层之间传输的次数。

1.2 基本操作的 I/O 复杂度

操作 I/O 复杂度 代表算法
扫描 N 个元素 O(N/B) 顺序读写
搜索(有序) O(log_B N) B-tree 搜索
排序 O((N/B) log_{M/B}(N/B)) 外部归并排序
置换(Permutation) O(min(N, Sort(N))) 取决于 N/M 的关系

排序的 I/O 复杂度不是 O(N log N / B),而是更复杂的表达式。差异来自于归并排序可以同时合并 M/B 路数据流。

1.3 B-tree:缓存感知的典范

/* B-tree 的分支因子直接取决于块大小 B——需要知道 BLOCK_SIZE */
#define BTREE_ORDER  (BLOCK_SIZE / sizeof(key_t))

struct btree_node {
    int num_keys;
    key_t keys[2 * BTREE_ORDER - 1];
    struct btree_node *children[2 * BTREE_ORDER];
};

B-tree 将分支因子设为 Theta(B),每个节点恰好装满一个磁盘块,从根到叶 O(log_B N) 步,每步一次 I/O。

问题在于:B-tree 的效率依赖于对 B 的精确知识。现代计算机至少有 4 层缓存(L1、L2、L3、主存),每层的 B 和 M 都不同,你不可能同时为所有层级都调到最优。

二、缓存无关模型

2.1 核心思想

缓存无关模型(Cache-Oblivious Model)由 Frigo、Leiserson、Prokop 和 Ramachandran 在 1999 年提出。核心假设:

算法不知道 B 和 M 的值,但我们在分析其 I/O 复杂度时,仍然使用这两个参数。

算法代码中看不到任何与缓存相关的参数,但对于任意的 B 和 M,它都能达到最优 I/O 复杂度。

2.2 理想缓存假设

模型引入两个理想化假设:

  1. 全相联(Fully Associative):任何内存块可以缓存到任何位置,没有冲突缺失
  2. 最优替换(Optimal Replacement):使用 Belady 算法——总是驱逐将来最晚访问的块

关键支撑定理——正则性引理:理想缓存上 M 大小内存的 I/O 次数,与 LRU 缓存上 2M 大小内存的 I/O 次数渐近相同。理想缓存的分析在真实 LRU 缓存上成立。

2.3 为什么不需要知道参数

缓存无关算法在所有尺度上递归分解问题。在某个递归层次上,子问题大小恰好等于 M,数据布局恰好与 B 对齐——尽管算法本身并不知道:

递归分解:

        大问题 (>> M)
       /     |     \
    中问题  中问题  中问题     ← 某一层恰好 ≈ M
   / | \   / | \   / | \
  小  小  小  小  小  小  ...   ← 某一层恰好 ≈ B

算法遍历所有尺度,必有一个尺度与缓存匹配!

2.4 威力与局限

威力:自动适应 L1/L2/L3/主存所有层级;无需运行时检测硬件参数;可移植性极好。

局限:常数因子通常比精心调优的缓存感知算法大;理想缓存假设有时过于乐观;动态操作比静态困难得多。

三、缓存无关 B-tree:van Emde Boas 布局

3.1 问题与动机

给定完全二叉搜索树,不同内存布局下的搜索 I/O:

van Emde Boas 布局在不知道 B 的情况下达到这个最优界。

3.2 递归构造

给定高度为 h 的完全二叉树:

  1. 令 h_top = floor(h/2),h_bottom = ceil(h/2)
  2. 上半部分(根以及前 h_top 层)构成子树 T_top
  3. 每个 T_top 的叶子各自连接高度为 h_bottom 的底部子树
  4. 内存中先放 T_top 的 vEB 布局,再依次放每棵底部子树的 vEB 布局
  5. 每棵子树内部递归应用同样规则
van Emde Boas 内存布局示意图
function vEB_layout(tree T of height h):
    if h == 1: 输出 T 的单个节点; return
    h_top = floor(h / 2)
    T_top = T 的前 h_top 层
    B_0..B_{k-1} = T 的底部子树(k = 2^h_top)
    vEB_layout(T_top)
    for i = 0 to k-1:
        vEB_layout(B_i)

3.3 为什么能达到 O(log_B N)

由于布局在所有尺度上递归分割,必然存在某个递归层次使得子树大小在 Theta(B) 到 Theta(B^2) 之间。在这个层次上,每棵子树完全装入 O(1) 个缓存行,总共需要访问 O(log N / log B) = O(log_B N) 棵子树。

3.4 形式化证明

设树高 h = log_2 N。选取递归层次使 h’ = floor(log_2 B),此时每棵子树包含至多 B 个节点,占据 O(1) 个缓存行。根到叶穿过 ceil(h / h’) = O(log_B N) 棵子树,每棵至多 O(1) 次缓存缺失。总 I/O:O(log_B N)。

3.5 完整 C 语言实现

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

/*
 * vEB 布局的静态搜索树
 * 使用预计算的位置映射表实现高效搜索
 */
typedef struct {
    int *data;    /* vEB 布局的数据数组 */
    int *pos;     /* pos[bfs_id] = vEB 数组中的位置 */
    int  n;       /* 节点总数(2^h - 1) */
    int  height;
} veb_tree_t;

static int tree_height(int n)
{
    int h = 0, s = 1;
    while (s - 1 < n) { s <<= 1; h++; }
    return h;
}

/* 递归计算 vEB 布局的位置映射 */
static void compute_veb_pos(int bfs_root, int height, int veb_start,
                             int total_n, int *pos)
{
    if (height <= 0 || bfs_root > total_n) return;

    if (height == 1) {
        if (bfs_root <= total_n) pos[bfs_root] = veb_start;
        return;
    }

    int h_top    = height / 2;
    int h_bottom = height - h_top;
    int top_size = (1 << h_top) - 1;
    int bot_size = (1 << h_bottom) - 1;

    /* BFS 遍历上半部分,收集节点编号 */
    int *top_nodes = malloc(top_size * sizeof(int));
    int *queue = malloc((top_size + 1) * sizeof(int));
    int qh = 0, qt = 0, count = 0;
    queue[qt++] = bfs_root;

    for (int lv = 0; lv < h_top && qh < qt; lv++) {
        int lv_cnt = qt - qh;
        for (int i = 0; i < lv_cnt; i++) {
            int nd = queue[qh++];
            if (nd <= total_n && count < top_size) top_nodes[count++] = nd;
            if (lv < h_top - 1) {
                if (2 * nd <= total_n) queue[qt++] = 2 * nd;
                if (2 * nd + 1 <= total_n) queue[qt++] = 2 * nd + 1;
            }
        }
    }

    /* 递归处理上半部分 */
    compute_veb_pos(bfs_root, h_top, veb_start, total_n, pos);

    /* 处理底部子树 */
    int num_top_leaves = 1 << (h_top - 1);
    int bottom_off = veb_start + top_size;
    int leaf_start = top_size - num_top_leaves;
    int si = 0;

    for (int i = leaf_start; i < count; i++) {
        int leaf = top_nodes[i];
        if (2 * leaf <= total_n) {
            compute_veb_pos(2 * leaf, h_bottom,
                            bottom_off + si * bot_size, total_n, pos);
            si++;
        }
        if (2 * leaf + 1 <= total_n) {
            compute_veb_pos(2 * leaf + 1, h_bottom,
                            bottom_off + si * bot_size, total_n, pos);
            si++;
        }
    }
    free(top_nodes);
    free(queue);
}

veb_tree_t *veb_build(const int *sorted, int n)
{
    veb_tree_t *t = malloc(sizeof(veb_tree_t));
    t->height = tree_height(n);
    t->n = (1 << t->height) - 1;
    t->data = malloc((t->n + 1) * sizeof(int));
    t->pos  = malloc((t->n + 1) * sizeof(int));

    /* 构建 BFS 序的 BST(中序遍历填值) */
    int *bfs = calloc(t->n + 1, sizeof(int));
    int *stk = malloc(t->n * sizeof(int));
    int sp = 0, si = 0, nd = 1;
    while (sp > 0 || nd <= t->n) {
        while (nd <= t->n) { stk[sp++] = nd; nd = 2 * nd; }
        nd = stk[--sp];
        bfs[nd] = (si < n) ? sorted[si++] : 0x7fffffff;
        nd = 2 * nd + 1;
    }

    /* 计算位置映射并填充 vEB 数组 */
    memset(t->pos, -1, (t->n + 1) * sizeof(int));
    compute_veb_pos(1, t->height, 0, t->n, t->pos);
    for (int i = 1; i <= t->n; i++)
        if (t->pos[i] >= 0 && t->pos[i] < t->n)
            t->data[t->pos[i]] = bfs[i];

    free(bfs); free(stk);
    return t;
}

/* 搜索:使用偏移表,真正的 O(log_B N) I/O */
int veb_search(const veb_tree_t *t, int key)
{
    int nd = 1;
    while (nd <= t->n) {
        int val = t->data[t->pos[nd]];
        if (key == val) return 1;
        nd = (key < val) ? 2 * nd : 2 * nd + 1;
    }
    return 0;
}

void veb_free(veb_tree_t *t)
{
    if (t) { free(t->data); free(t->pos); free(t); }
}

3.6 与标准布局的性能对比

在 N = 2^24(约 1600 万)个元素上随机搜索:

布局方式          搜索时间(ns/查询)   L1 命中率   LLC 命中率
─────────────────────────────────────────────────────────────
BFS 布局           ~420              72%        45%
DFS (中序) 布局    ~380              75%        52%
vEB 布局           ~290              81%        68%
B-tree (B=64)      ~250              85%        75%
排序数组+二分       ~350              74%        48%

vEB 布局接近精心调优的 B-tree,且不需要知道任何硬件参数。

四、Funnel Sort:缓存无关最优排序

4.1 排序的 I/O 下界

在外部存储模型中,排序 N 个元素的 I/O 下界为:

Sort(N) = Theta((N/B) * log_{M/B}(N/B))

传统外部归并排序(M/B 路归并)可达此下界,但需要知道 M 和 B。

4.2 Funnel Sort 概述

Funnel Sort 由 Frigo 等人设计,在缓存无关模型下达到 I/O 排序最优界。结构由两个核心组件构成:

  1. 递归分割:将输入分为 N^{1/3} 段,每段 N^{2/3} 个元素
  2. k-merger:递归构造的多路合并器

4.3 k-merger 的构造

一个 k-merger 能从 k 个有序输入流中产生 k^3 个有序输出:

k 个输入流分成 sqrt(k) 组,每组 sqrt(k) 个流:

输入流 1 ─┐
输入流 2 ─┤ sqrt(k)-merger ──┐
  ...     ─┘                  │
                               ├── sqrt(k)-merger ── 输出
输入流 k-1 ─┐                │
输入流 k   ─┤ sqrt(k)-merger ──┘
  ...       ─┘

相邻层的合并器之间有大小为 k^{3/2} 的缓冲区。当 k^2 <= M/B 时,整个 k-merger 装入缓存。

4.4 I/O 复杂度

递推关系 T(N) = N^{1/3} * T(N^{2/3}) + O(N/B * log_{M/B}(N/B)),解得:

T(N) = O((N/B) * log_{M/B}(N/B))

与外部归并排序相同,但无需知道 M 和 B。

4.5 实践中的替代方案

Funnel Sort 在理论上很美,实际中较少使用(实现复杂、常数大)。更常用:

五、缓存无关矩阵乘法:递归分块

矩阵运算是缓存优化收益最显著的场景。

5.1 朴素矩阵乘法的 I/O 分析

C = A * B,n x n 矩阵按行主序存储。朴素三层循环中,B 矩阵按列访问导致 I/O 为 O(n^3)——几乎没有从缓存获益。

5.2 缓存感知分块(需要知道 M)

将矩阵分为 sqrt(M) x sqrt(M) 的块,I/O 达到 O(n^3 / (B * sqrt(M)))。但 BLOCK_SIZE 依赖于 CACHE_SIZE,且只能针对一个缓存层级优化。

5.3 递归分块(缓存无关版)

/* co_matmul.c - 缓存无关矩阵乘法 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <math.h>

#define CUTOFF 64

static double now_ms(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec * 1000.0 + ts.tv_nsec / 1e6;
}

/*
 * 递归缓存无关矩阵乘法 C += A * B
 *
 * [C00 C01]   [A00 A01]   [B00 B01]
 * [C10 C11] = [A10 A11] * [B10 B11]
 *
 * 8 次递归乘法,自动在某层匹配缓存大小
 */
static void matmul_base(double *C, const double *A, const double *B,
                          int n, int N)
{
    for (int i = 0; i < n; i++)
        for (int k = 0; k < n; k++) {
            double a = A[i * N + k];
            for (int j = 0; j < n; j++)
                C[i * N + j] += a * B[k * N + j];
        }
}

void matmul_co(double *C, const double *A, const double *B,
                int n, int N)
{
    if (n <= CUTOFF) { matmul_base(C, A, B, n, N); return; }

    int h = n / 2;
    const double *A00 = A, *A01 = A + h;
    const double *A10 = A + h*N, *A11 = A + h*N + h;
    const double *B00 = B, *B01 = B + h;
    const double *B10 = B + h*N, *B11 = B + h*N + h;
    double *C00 = C, *C01 = C + h;
    double *C10 = C + h*N, *C11 = C + h*N + h;

    matmul_co(C00, A00, B00, h, N);  matmul_co(C00, A01, B10, h, N);
    matmul_co(C01, A00, B01, h, N);  matmul_co(C01, A01, B11, h, N);
    matmul_co(C10, A10, B00, h, N);  matmul_co(C10, A11, B10, h, N);
    matmul_co(C11, A10, B01, h, N);  matmul_co(C11, A11, B11, h, N);
}

/* 朴素 ijk 顺序(对照组) */
void matmul_naive(double *C, const double *A, const double *B, int n)
{
    for (int i = 0; i < n; i++)
        for (int j = 0; j < n; j++) {
            double s = 0;
            for (int k = 0; k < n; k++) s += A[i*n+k] * B[k*n+j];
            C[i*n+j] += s;
        }
}

/* 朴素 ikj 顺序(更好的缓存行为) */
void matmul_ikj(double *C, const double *A, const double *B, int n)
{
    for (int i = 0; i < n; i++)
        for (int k = 0; k < n; k++) {
            double a = A[i*n+k];
            for (int j = 0; j < n; j++) C[i*n+j] += a * B[k*n+j];
        }
}

int main(int argc, char *argv[])
{
    int n = (argc > 1) ? atoi(argv[1]) : 512;
    n = (n / CUTOFF) * CUTOFF;
    if (n == 0) n = CUTOFF;

    printf("矩阵大小: %d x %d\n", n, n);
    double *A  = calloc(n*n, sizeof(double));
    double *B  = calloc(n*n, sizeof(double));
    double *C1 = calloc(n*n, sizeof(double));
    double *C2 = calloc(n*n, sizeof(double));
    double *C3 = calloc(n*n, sizeof(double));

    srand(42);
    for (int i = 0; i < n*n; i++) {
        A[i] = (double)(rand() % 100) / 100.0;
        B[i] = (double)(rand() % 100) / 100.0;
    }

    double t;
    t = now_ms(); matmul_naive(C1, A, B, n);
    printf("朴素 ijk:  %8.1f ms\n", now_ms() - t);
    t = now_ms(); matmul_ikj(C2, A, B, n);
    printf("朴素 ikj:  %8.1f ms\n", now_ms() - t);
    t = now_ms(); matmul_co(C3, A, B, n, n);
    printf("缓存无关:  %8.1f ms\n", now_ms() - t);

    /* 验证正确性 */
    double md = 0;
    for (int i = 0; i < n*n; i++) {
        double d = fabs(C1[i] - C3[i]);
        if (d > md) md = d;
    }
    printf("最大误差: %.2e\n", md);

    free(A); free(B); free(C1); free(C2); free(C3);
    return 0;
}

5.4 I/O 复杂度分析

递推 Q(n) = 8 * Q(n/2) + O(n^2/B)。当 n <= c * sqrt(M) 时(三个子矩阵装入缓存),Q(n) = O(n^2/B)。求解得:

Q(n) = O(n^3 / (B * sqrt(M)))

正是矩阵乘法的 I/O 最优界,而代码中完全没有 B 和 M。

5.5 为什么递归自动匹配缓存

递归树:
n=1024  ->  n=512  ->  n=256  ->  n=128(假设 M ≈ 3*128^2)
                                    ^
                                    此层子矩阵刚好装入缓存

无论 M 是 100KB 还是 10MB,递归总会在某一层自动命中。

六、缓存无关矩阵转置

6.1 问题分析

朴素矩阵转置中,写入 B[j][i] 是列访问,每次可能缓存缺失,总 I/O 为 O(n^2),而最优应该是 O(n^2/B)。

6.2 缓存无关分治法

/*
 * 缓存无关矩阵转置:B = A^T
 * A: m x n,行步长 lda;B: n x m,行步长 ldb
 */
void transpose_co(double *B, const double *A,
                   int m, int n, int lda, int ldb)
{
    if (m <= 32 && n <= 32) {
        for (int i = 0; i < m; i++)
            for (int j = 0; j < n; j++)
                B[j * ldb + i] = A[i * lda + j];
        return;
    }

    if (m >= n) {
        int half = m / 2;
        transpose_co(B, A, half, n, lda, ldb);
        transpose_co(B + half, A + half * lda, m - half, n, lda, ldb);
    } else {
        int half = n / 2;
        transpose_co(B, A, m, half, lda, ldb);
        transpose_co(B + half * ldb, A + half, m, n - half, lda, ldb);
    }
}

6.3 I/O 分析

递推 Q(n) = 4*Q(n/2) + O(1),当 n^2 <= M 时 Q(n) = O(n^2/B)。解得总 I/O 为 O(n^2/B)——达到最优。

当递归到子矩阵 sqrt(M) x sqrt(M) 时整个子矩阵装入缓存,后续转置在缓存内完成。

七、缓存无关优先队列

动态数据结构比静态结构困难得多。

7.1 Arge-Bender-Demaine-Farach-Colton 优先队列

Arge 等人 2002 年提出的缓存无关优先队列,达到与外部存储模型下最优界相同的 I/O 复杂度:

7.2 基本结构

基于指数增长的分层缓冲区:

层级 0:  大小 x^3(x 在分析中取 M^{1/3})
层级 1:  大小 x^3
层级 2:  大小 x^6
层级 i:  大小 x^{3·2^{i-1}}
每个层级内部保持有序。

7.3 操作流程

Insert:插入层级 0,满了则排序后与层级 1 合并,级联向下推。

DeleteMin:返回层级 0 最小元素,空了则从层级 1 提取最小的 x^3 个元素,级联向上补充。

合并操作是顺序扫描,I/O 效率高。每个元素在每个层级至多被合并一次,总共 O(log log N) 个层级。

7.4 实践考虑

缓存无关优先队列理论漂亮但实践中面临挑战:实现复杂、常数因子大、只有数据量远超缓存时才有意义。大多数场景中,一个 d-ary 堆(d ≈ 缓存行容纳的元素数)就已经足够好。

八、实测:缓存命中率的量化对比

8.1 测试环境

CPU:        Intel Core i7-12700K
L1d Cache:  48 KB/core, 64B line
L2 Cache:   1.25 MB/core
L3 Cache:   25 MB shared
Compiler:   gcc 12.3 -O2 -march=native

8.2 矩阵乘法(n=1024,double)

算法              时间(ms)  L1d Miss Rate  LLC Miss Rate  L1d MPKI
────────────────────────────────────────────────────────────────────
朴素 ijk           5842      18.2%          42.3%          285.4
朴素 ikj           2134       4.7%          15.6%           73.8
分块 (64x64)        891       1.2%           3.8%           18.7
递归缓存无关         952       1.5%           4.1%           23.4
OpenBLAS dgemm      287       0.4%           0.9%            6.3

递归缓存无关接近手动分块,无需调参。OpenBLAS 还用了 SIMD 等额外优化。

8.3 矩阵转置(n=4096,double)

算法              时间(ms)  L1d Miss Rate  LLC Miss Rate
─────────────────────────────────────────────────────────
朴素转置            312       24.7%          35.1%
分块 (32x32)         45        2.1%           1.8%
递归缓存无关          51        2.4%           2.1%

缓存无关版本比朴素版本快 6 倍。

8.4 树搜索(N=2^24,随机查询 10^6 次)

布局              时间(ms)  L1d Miss Rate  LLC Miss Rate
─────────────────────────────────────────────────────────
BFS 布局            423       12.4%          18.7%
中序 (DFS) 布局     389       10.8%          15.2%
vEB 布局            298        6.7%           8.3%
B-tree (B=16)       261        5.2%           6.1%
排序数组二分查找     356       11.1%          16.8%

8.5 perf 命令参考

# L1 和 LLC 缓存命中率
perf stat -e L1-dcache-loads,L1-dcache-load-misses,\
LLC-loads,LLC-load-misses ./program

# TLB 缺失
perf stat -e dTLB-loads,dTLB-load-misses ./program

# 定位热点
perf record -e cache-misses ./program && perf report --sort=symbol

九、理论 vs 实践的差距

9.1 常数因子

缓存无关算法常数更大:递归调用开销、vEB 地址计算复杂、对齐问题。

9.2 TLB 缺失

缓存无关模型没有考虑 TLB。递归分治可能使数据分布在大量页面上,即使数据缓存命中率高,TLB 缺失也可能成为瓶颈。

# 解决方案:使用大页(Huge Pages)
echo always > /sys/kernel/mm/transparent_hugepage/enabled
#include <sys/mman.h>
void *ptr = mmap(NULL, size, PROT_READ | PROT_WRITE,
                  MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0);

9.3 预取

现代 CPU 的硬件预取器能检测顺序访问模式并提前加载数据,但递归分治的访问模式可能不够规律,导致预取器失效。

/* 手动预取帮助搜索,但破坏了缓存无关的纯粹性 */
#include <xmmintrin.h>
_mm_prefetch(&veb_data[offset_table[2 * node]], _MM_HINT_T0);
_mm_prefetch(&veb_data[offset_table[2 * node + 1]], _MM_HINT_T0);

9.4 编译器优化的交互

编译选项                    朴素乘法(ms)  递归乘法(ms)
──────────────────────────────────────────────────────
-O0                          12340         11890
-O2                           5842           952
-O3 -march=native             2890           845

-O3 对朴素乘法帮助大(编译器自动做循环变换),但递归版改善小(递归本身已提供良好局部性)。

9.5 工程实践要点表

维度 理论假设 实际情况 对策
缓存替换 最优 (Belady) LRU/伪LRU M 翻倍即可等价
相联度 全相联 8-16 路 冲突缺失通常不严重
缓存行大小 统一 B L1=64B, 磁盘=4KB 多层级自动适配
TLB 未建模 有限条目 使用大页
预取 未建模 硬件预取器 顺序访问仍占优势
对齐 完美对齐 可能跨行 控制基础情况大小
常数因子 忽略 2-5 倍 基础情况切换朴素算法
递归开销 忽略 函数调用开销 尾递归优化/迭代化
编译器 未考虑 可能等价变换 -O2 通常足够
SIMD 未建模 4-8 倍加速 正交优化,可叠加

十、个人观点与实践建议

10.1 缓存无关算法是理论的胜利

Frigo 等人 1999 年的论文是计算机科学中少有的让人拍大腿的成果。不知道硬件参数却能在所有层级最优——美感不亚于 Shannon 定理。但理论的美感和实践的价值不总是正相关。

10.2 递归分治本身就是好的

最大的启示不是”使用 vEB 布局”或”实现 Funnel Sort”,而是更普适的原则:递归分治自然地提供良好的缓存行为。 你只需优先选择递归分治而不是迭代扫描,就能获得很多好处。

/* 不太好:大范围迭代 */
for (int i = 0; i < n; i++)
    process(arr[i]);

/* 更好:递归分治,小问题完全在缓存内 */
void solve(int *arr, int lo, int hi) {
    if (hi - lo < THRESHOLD) { base_case(arr, lo, hi); return; }
    int mid = (lo + hi) / 2;
    solve(arr, lo, mid);
    solve(arr, mid + 1, hi);
    merge(arr, lo, mid, hi);
}

10.3 适用场景

缓存无关技术最有价值:科学计算矩阵运算、大规模静态搜索、跨平台高性能库。

传统方法更实用:数据在 L3 内、需要极致性能(手动调优+SIMD 更快)、动态数据结构。

10.4 给工程师的建议

  1. 先量化再优化:用 perf stat 确认缓存缺失确实是瓶颈
  2. ikj > ijk:矩阵乘法循环顺序比任何花哨算法都重要
  3. 递归 + 基础情况切换:递归到足够小时切换到朴素算法
  4. 不要过度工程std::sort 够快就别实现 Funnel Sort
  5. 理解原理比记住算法更重要

10.5 关于理论研究的价值

有人说缓存无关算法常数太大没什么用——我不同意。理论研究的价值在于揭示深层原理:

这些洞察即使你不使用任何缓存无关算法,也能指导你写出更好的代码。

10.6 FFTW:缓存无关思想的工程典范

FFTW(Fastest Fourier Transform in the West)是缓存无关理论在实际工程中最成功的应用。它由 Frigo 和 Johnson 开发——没错,Frigo 正是缓存无关论文的第一作者。

FFTW 的核心策略:

  1. 将 FFT 递归分解为小尺寸的”codelets”(由代码生成器自动生成)
  2. 运行时通过”planner”搜索最优的递归分解方案
  3. 递归结构自动适应缓存层次

虽然 FFTW 的 planner 本身是缓存感知的(它在运行时探测性能),但其算法的递归骨架正是缓存无关的精髓。它在几乎所有平台上都能达到接近手写汇编的性能,这是缓存无关思想实用性的最好证明。

十一、总结

11.1 核心要点

概念 核心思想 I/O 复杂度
外部存储模型 两层存储+块传输 用 B、M 分析
缓存无关模型 不知 B、M 但自动最优 同上但无需参数
vEB 布局 递归二分存储 O(log_B N) 搜索
Funnel Sort 递归 k-merger O((N/B) log_{M/B}(N/B))
递归矩阵乘法 递归 2x2 分块 O(n^3/(B sqrt(M)))
递归矩阵转置 沿长边分割 O(n^2/B)
缓存无关优先队列 指数增长层级 O((1/B) log_{M/B}(N/B))

11.2 开放问题

  1. 动态 vEB 布局:支持插入/删除同时保持布局
  2. 多核缓存无关:共享 L3 缓存下的模型扩展
  3. 实用化:如何将理论转化为工程库

11.3 参考文献

  1. Frigo M, Leiserson CE, Prokop H, Ramachandran S. “Cache-Oblivious Algorithms.” FOCS 1999.
  2. Prokop H. “Cache-Oblivious Algorithms.” MIT Master’s Thesis, 1999.
  3. Demaine E. “Cache-Oblivious Algorithms and Data Structures.” EEF Summer School, 2002.
  4. Bender MA, Demaine ED, Farach-Colton M. “Cache-Oblivious B-Trees.” SIAM J. Comput., 2005.
  5. Arge L, et al. “An Optimal Cache-Oblivious Priority Queue.” SIAM J. Comput., 2007.
  6. Aggarwal A, Vitter JS. “The I/O Complexity of Sorting and Related Problems.” CACM, 1988.
  7. Brodal GS, Fagerberg R. “Cache Oblivious Distribution Sweeping.” ICALP 2002.
  8. Frigo M, Johnson SG. “The Design and Implementation of FFTW3.” Proc. IEEE, 2005.
  9. Drepper U. “What Every Programmer Should Know About Memory.” 2007.

算法系列导航上一篇:垃圾回收算法全景 | 下一篇:无分支编程

相关阅读Van Emde Boas 树 | B-tree 深度解剖


By .