高性能计算,windows 下,c++观测cache line的速度区别

Cache Line 与 False Sharing 的性能演示demo(C++ / Windows)

运行效果:

运行效果:
 

Cache line demo, MSVC / Windows
sizeof(BigNode) = 64
sizeof(FalseSharingCounter) = 8
sizeof(NoFalseSharingCounter) = 128

Case 1: sequential int array: 364.125 ms, sink=2814749683220480
Case 2: one int per cache line: 840.602 ms, sink=2814749683220480
Case 3: false sharing: 2676.61 ms, sink=200000000
Case 4: no false sharing: 821.499 ms, sink=200000000

示例代码

#include <windows.h>
#include <iostream>
#include <vector>
#include <atomic>
#include <thread>
#include <chrono>

using Clock = std::chrono::high_resolution_clock;

constexpr int N = 32 * 1024 * 1024;      // 32M ints ≈ 128MB
constexpr int REPEAT = 5;
constexpr int LOOP = 100'000'000;

template <class F>
double bench(const char* name, F&& f) {
    auto t1 = Clock::now();
    volatile long long sink = f();
    auto t2 = Clock::now();

    double ms = std::chrono::duration<double, std::milli>(t2 - t1).count();
    std::cout << name << ": " << ms << " ms, sink=" << sink << "\n";
    return ms;
}

// 人为把结构体撑到约 64 字节,使「一个 int」独占一条缓存行(常见 CPU 缓存行 = 64B)。
// 原理:顺序遍历时每次只读 4 字节有效数据,却要加载整条缓存行,有效带宽利用率低。
struct BigNode {
    int value;
    char padding[60]; // 总大小约 64 bytes
};

// Case 3 用:两个 atomic 紧挨着放,通常落在同一条缓存行内。
// 多核下会触发「伪共享」——见 Case 3 注释。
struct FalseSharingCounter {
    std::atomic<int> a;
    std::atomic<int> b;
};

// 按 64 字节对齐,保证 atomic 独占一条缓存行;剩余字节用 padding 填满该行。
// Case 4 用:两核各改自己的行,MESI 不会在「无关变量」之间来回失效同一行。
struct alignas(64) PaddedAtomic {
    std::atomic<int> value;
    char padding[64 - sizeof(std::atomic<int>)];
};

struct NoFalseSharingCounter {
    PaddedAtomic a;
    PaddedAtomic b;
};

int main() {
    SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_HIGHEST);

    std::cout << "Cache line demo, MSVC / Windows\n";
    std::cout << "sizeof(BigNode) = " << sizeof(BigNode) << "\n";
    std::cout << "sizeof(FalseSharingCounter) = " << sizeof(FalseSharingCounter) << "\n";
    std::cout << "sizeof(NoFalseSharingCounter) = " << sizeof(NoFalseSharingCounter) << "\n\n";

    // Case 1:连续 int 数组(最佳空间局部性)
    std::vector<int> arr(N);
    for (int i = 0; i < N; i++) arr[i] = i;

    bench("Case 1: sequential int array", [&]() -> long long {
        long long sum = 0;
        for (int r = 0; r < REPEAT; r++) {
            for (int i = 0; i < N; i++) {
                sum += arr[i];
            }
        }
        return sum;
    });

    // Case 2:每个逻辑元素占满约一条缓存行(人为放大结构体)
    std::vector<BigNode> nodes(N);
    for (int i = 0; i < N; i++) nodes[i].value = i;

    bench("Case 2: one int per cache line", [&]() -> long long {
        long long sum = 0;
        for (int r = 0; r < REPEAT; r++) {
            for (int i = 0; i < (int)nodes.size(); i++) {
                sum += nodes[i].value;
            }
        }
        return sum;
    });

    // Case 3:伪共享(false sharing)
    bench("Case 3: false sharing", [&]() -> long long {
        FalseSharingCounter c;
        c.a = 0;
        c.b = 0;

        std::thread t1([&]() {
            for (int i = 0; i < LOOP; i++) {
                c.a.fetch_add(1, std::memory_order_relaxed);
            }
        });

        std::thread t2([&]() {
            for (int i = 0; i < LOOP; i++) {
                c.b.fetch_add(1, std::memory_order_relaxed);
            }
        });

        t1.join();
        t2.join();

        return c.a.load() + c.b.load();
    });

    // Case 4:用对齐 + 填充避免伪共享
    bench("Case 4: no false sharing", [&]() -> long long {
        NoFalseSharingCounter c;
        c.a.value = 0;
        c.b.value = 0;

        std::thread t1([&]() {
            for (int i = 0; i < LOOP; i++) {
                c.a.value.fetch_add(1, std::memory_order_relaxed);
            }
        });

        std::thread t2([&]() {
            for (int i = 0; i < LOOP; i++) {
                c.b.value.fetch_add(1, std::memory_order_relaxed);
            }
        });

        t1.join();
        t2.join();

        return c.a.value.load() + c.b.value.load();
    });

    return 0;
}

实验结论

可以看到:

  • 命中 Cache Line 的访问速度明显更快
  • Cache Line 独立时(避免 False Sharing)比共享时更快

核心观察点:

1. 浪费 Cache Line

struct BigNode {
    int value;
    char padding[60];
};

这里每次只使用 4 字节的数据,但 CPU 会搬运整条 Cache Line(通常 64 字节)。

因此:

  • 实际使用:4B
  • 实际搬运:64B

有效带宽利用率很低。


2. False Sharing

struct FalseSharingCounter {
    std::atomic<int> a;
    std::atomic<int> b;
};

a 和 b 很可能位于同一条 Cache Line。

两个线程分别修改:

thread1 -> a++
thread2 -> b++

虽然逻辑上互不相关,但由于位于同一条 Cache Line:

  • Core0 修改 a
  • Core1 修改 b

会不断让对方缓存失效(Invalidate)。


3. 避免 False Sharing

struct alignas(64) PaddedAtomic {
    std::atomic<int> value;
};

通过:

  • alignas(64)
  • Padding

强制让变量位于不同 Cache Line。

这样可以减少 Cache Coherency 开销。


什么是 Cache Line

Cache Line 是 CPU Cache 与内存之间搬运数据的最小单位。

并不是:

读取 1 个 int

CPU 就只读取:

4 bytes

实际上通常会一次读取:

64 bytes

到 Cache 中。

这 64 字节块就叫:

Cache Line

例子 1:顺序访问为什么快

int arr[1024];

long long sum = 0;
for (int i = 0; i < 1024; i++) {
    sum += arr[i];
}

假设:

sizeof(int) = 4 bytes
cache line = 64 bytes

则一条 Cache Line 可以容纳:

64 / 4 = 16 个 int

当 CPU 读取:

arr[0]

实际可能已经将:

arr[0] ~ arr[15]

全部加载到 L1 Cache。

之后访问:

arr[1]
arr[2]
...
arr[15]

大概率全部是:

L1 Cache Hit

因此顺序遍历数组非常快。


例子 2:跨 Cache Line 访问为什么慢

struct Node {
    int value;
    char padding[60];
};

Node arr[1024];

long long sum = 0;
for (int i = 0; i < 1024; i++) {
    sum += arr[i].value;
}

这里:

sizeof(Node) ≈ 64 bytes

每个元素几乎独占一条 Cache Line。

每次访问:

arr[i].value

都需要新的 Cache Line。

实际效果:

访问 arr[0].value
→ 搬 64B
→ 使用 4B

访问 arr[1].value
→ 搬 64B
→ 使用 4B

访问 arr[2].value
→ 搬 64B
→ 使用 4B

导致:

  • Cache 带宽浪费
  • Cache Miss 增多
  • 吞吐下降

例子 3:两个线程写不同变量也可能变慢

struct Counter {
    std::atomic<int> a;
    std::atomic<int> b;
};

Counter c;

线程:

void thread1() {
    for (int i = 0; i < 100000000; i++) {
        c.a++;
    }
}

void thread2() {
    for (int i = 0; i < 100000000; i++) {
        c.b++;
    }
}

表面看:

thread1 只写 a
thread2 只写 b

似乎互不影响。

但如果:

a 与 b 位于同一条 Cache Line

就会产生 False Sharing。


为什么会发生 False Sharing

内存布局可能如下:

Cache Line (64B)

| a | b | ........ |

注意:

a 和 b 在同一条 Cache Line

多核 CPU 下发生了什么

假设:

Core0
└── L1 Cache

Core1
└── L1 Cache

两个核心都读取到了:

[a, b]

因此:

Core0:
[a,b]

Core1:
[a,b]

MESI 协议开始工作

CPU 为了保证缓存一致性(Cache Coherency),使用 MESI 等协议。

初始状态:

Core0 : Shared(S)

Core1 : Shared(S)

双方都可以读取。


thread1 执行 a++

实际上:

a++

并不是一步操作,而是:

Read
Modify
Write

因此 Core0 必须获得:

独占写权限

于是 CPU 会通知:

Core1:
你的 Cache Line 作废

结果:

Core1:
Invalid(I)

thread2 执行 b++

Core1 想修改:

b++

但是:

对应 Cache Line 已失效

因此必须重新获取整条 Cache Line。

随后 CPU 又会:

让 Core0 的 Cache Line 失效

最终发生什么

两个核心不断进行:

Invalidate
Refetch
Invalidate
Refetch

类似:

Core0:
这是我的!

Core1:
不,这是我的!

Core0:
滚!

Core1:
你滚!

这就是 False Sharing

虽然:

a 与 b 是不同变量

但因为:

位于同一条 Cache Line

导致:

像共享变量一样互相争抢

这就是:

False Sharing(伪共享)

为什么会慢很多

真正昂贵的不是:

add

而是:

Cache Coherency Traffic

即核心之间的同步通信。

可能涉及:

L1
↕
L2
↕
L3
↕
Core-to-Core Fabric

甚至:

Ring Bus
Infinity Fabric

这些通信成本远高于普通整数加法。


Case 4 为什么更快

struct alignas(64) PaddedAtomic {
    std::atomic<int> value;
    char padding[60];
};

内存布局:

Cache Line 1
└── a

Cache Line 2
└── b

此时:

Core0
独占 Cache Line A

Core1
独占 Cache Line B

双方不会相互失效缓存。

因此:

  • 不需要 Invalidate
  • 不需要 Refetch
  • 一致性开销大幅下降

性能显著提升。


一句话理解

Cache Line 是 CPU 进行缓存、搬运和缓存一致性管理的最小单位,而不是变量。

例如:

64 bytes

CPU 管理的是:

整条 Cache Line

而不是:

int a
int b

单独的变量。

因此在高性能 C++ 中非常关注:

  • 数据是否连续
  • 数据是否对齐
  • 是否浪费 Cache Line
  • 多线程是否写入同一条 Cache Line

为什么数组通常比链表快

数组:

连续内存

优点:

  • Cache Friendly
  • 预取有效
  • Cache Hit 高

链表:

离散内存

缺点:

  • 指针跳转
  • Cache Miss 多
  • CPU 预取困难

因此在很多场景下:

顺序遍历数组的性能远高于遍历链表。