高性能计算,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 预取困难
因此在很多场景下:
顺序遍历数组的性能远高于遍历链表。