Windows 10 与 Windows 7 相比性能较差(页面错误处理不可扩展,线程数 > 16 时锁争用严重)
Windows 10 poor performance compared to Windows 7 (page fault handling is not scalable, severe lock contention when no of threads > 16)
我们设置了两个具有以下规格的相同 HP Z840 工作站
- 2 x Xeon E5-2690 v4 @ 2.60GHz(Turbo Boost 开启,HT 关闭,总共 28 个逻辑 CPU)
- 32GB DDR4 2400 内存,四通道
并安装了 Windows 7 SP1 (x64) 和 Windows 10 Creators Update (x64)。
然后我们 运行 一个小型内存基准测试(下面的代码,使用 VS2015 Update 3,64 位架构构建),它从多个线程同时执行内存分配 - 无填充。
#include <Windows.h>
#include <vector>
#include <ppl.h>
unsigned __int64 ZQueryPerformanceCounter()
{
unsigned __int64 c;
::QueryPerformanceCounter((LARGE_INTEGER *)&c);
return c;
}
unsigned __int64 ZQueryPerformanceFrequency()
{
unsigned __int64 c;
::QueryPerformanceFrequency((LARGE_INTEGER *)&c);
return c;
}
class CZPerfCounter {
public:
CZPerfCounter() : m_st(ZQueryPerformanceCounter()) {};
void reset() { m_st = ZQueryPerformanceCounter(); };
unsigned __int64 elapsedCount() { return ZQueryPerformanceCounter() - m_st; };
unsigned long elapsedMS() { return (unsigned long)(elapsedCount() * 1000 / m_freq); };
unsigned long elapsedMicroSec() { return (unsigned long)(elapsedCount() * 1000 * 1000 / m_freq); };
static unsigned __int64 frequency() { return m_freq; };
private:
unsigned __int64 m_st;
static unsigned __int64 m_freq;
};
unsigned __int64 CZPerfCounter::m_freq = ZQueryPerformanceFrequency();
int main(int argc, char ** argv)
{
SYSTEM_INFO sysinfo;
GetSystemInfo(&sysinfo);
int ncpu = sysinfo.dwNumberOfProcessors;
if (argc == 2) {
ncpu = atoi(argv[1]);
}
{
printf("No of threads %d\n", ncpu);
try {
concurrency::Scheduler::ResetDefaultSchedulerPolicy();
int min_threads = 1;
int max_threads = ncpu;
concurrency::SchedulerPolicy policy
(2 // two entries of policy settings
, concurrency::MinConcurrency, min_threads
, concurrency::MaxConcurrency, max_threads
);
concurrency::Scheduler::SetDefaultSchedulerPolicy(policy);
}
catch (concurrency::default_scheduler_exists &) {
printf("Cannot set concurrency runtime scheduler policy (Default scheduler already exists).\n");
}
static int cnt = 100;
static int num_fills = 1;
CZPerfCounter pcTotal;
// malloc/free
printf("malloc/free\n");
{
CZPerfCounter pc;
for (int i = 1 * 1024 * 1024; i <= 8 * 1024 * 1024; i *= 2) {
concurrency::parallel_for(0, 50, [i](size_t x) {
std::vector<void *> ptrs;
ptrs.reserve(cnt);
for (int n = 0; n < cnt; n++) {
auto p = malloc(i);
ptrs.emplace_back(p);
}
for (int x = 0; x < num_fills; x++) {
for (auto p : ptrs) {
memset(p, num_fills, i);
}
}
for (auto p : ptrs) {
free(p);
}
});
printf("size %4d MB, elapsed %8.2f s, \n", i / (1024 * 1024), pc.elapsedMS() / 1000.0);
pc.reset();
}
}
printf("\n");
printf("Total %6.2f s\n", pcTotal.elapsedMS() / 1000.0);
}
return 0;
}
令人惊讶的是,与 Windows 7 相比,Windows 10 CU 的结果非常糟糕。我在下面绘制了 1MB 块大小和 8MB 块大小的结果,线程数从 2 ,4,.., 最多 28。当我们增加线程数时,Windows 7 的性能稍差,而 Windows 10 的可扩展性更差。
我们已尝试确保应用所有 Windows 更新、更新驱动程序、调整 BIOS 设置,但均未成功。我们也在其他几个硬件平台上运行做了同样的benchmark,都给了Windows10相似的曲线。看来是Windows10的问题。
有没有人有类似的经验,或者知道这方面的知识(也许我们错过了什么?)。这种行为使我们的多线程应用程序受到了显着的性能影响。
*** 已编辑
使用https://github.com/google/UIforETW(感谢Bruce Dawson)分析基准,我们发现大部分时间都花在了内核KiPageFault 内部。进一步深入调用树,所有这些都会导致 ExpWaitForSpinLockExclusiveAndAcquire。似乎是锁争用导致了这个问题。
*** 已编辑
在同一硬件上收集 Server 2012 R2 数据。 Server 2012 R2 也比 Win7 差,但还是比 Win10 CU 好很多。
*** 已编辑
它也发生在 Server 2016 中。我添加了标签 windows-server-2016.
*** 已编辑
使用来自@Ext3h 的信息,我修改了基准以使用 VirtualAlloc 和 VirtualLock。与不使用 VirtualLock 时相比,我可以确认有显着改进。总体而言,Win10 在同时使用 VirtualAlloc 和 VirtualLock 时仍然比 Win7 慢 30% 到 40%。
很遗憾,这不是答案,只是一些额外的见解。
使用不同分配策略的小实验:
#include <Windows.h>
#include <thread>
#include <condition_variable>
#include <mutex>
#include <queue>
#include <atomic>
#include <iostream>
#include <chrono>
class AllocTest
{
public:
virtual void* Alloc(size_t size) = 0;
virtual void Free(void* allocation) = 0;
};
class BasicAlloc : public AllocTest
{
public:
void* Alloc(size_t size) override {
return VirtualAlloc(NULL, size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
}
void Free(void* allocation) override {
VirtualFree(allocation, NULL, MEM_RELEASE);
}
};
class ThreadAlloc : public AllocTest
{
public:
ThreadAlloc() {
t = std::thread([this]() {
std::unique_lock<std::mutex> qlock(this->qm);
do {
this->qcv.wait(qlock, [this]() {
return shutdown || !q.empty();
});
{
std::unique_lock<std::mutex> rlock(this->rm);
while (!q.empty())
{
q.front()();
q.pop();
}
}
rcv.notify_all();
} while (!shutdown);
});
}
~ThreadAlloc() {
{
std::unique_lock<std::mutex> lock1(this->rm);
std::unique_lock<std::mutex> lock2(this->qm);
shutdown = true;
}
qcv.notify_all();
rcv.notify_all();
t.join();
}
void* Alloc(size_t size) override {
void* target = nullptr;
{
std::unique_lock<std::mutex> lock(this->qm);
q.emplace([this, &target, size]() {
target = VirtualAlloc(NULL, size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
VirtualLock(target, size);
VirtualUnlock(target, size);
});
}
qcv.notify_one();
{
std::unique_lock<std::mutex> lock(this->rm);
rcv.wait(lock, [&target]() {
return target != nullptr;
});
}
return target;
}
void Free(void* allocation) override {
{
std::unique_lock<std::mutex> lock(this->qm);
q.emplace([allocation]() {
VirtualFree(allocation, NULL, MEM_RELEASE);
});
}
qcv.notify_one();
}
private:
std::queue<std::function<void()>> q;
std::condition_variable qcv;
std::condition_variable rcv;
std::mutex qm;
std::mutex rm;
std::thread t;
std::atomic_bool shutdown = false;
};
int main()
{
SetProcessWorkingSetSize(GetCurrentProcess(), size_t(4) * 1024 * 1024 * 1024, size_t(16) * 1024 * 1024 * 1024);
BasicAlloc alloc1;
ThreadAlloc alloc2;
AllocTest *allocator = &alloc2;
const size_t buffer_size =1*1024*1024;
const size_t buffer_count = 10*1024;
const unsigned int thread_count = 32;
std::vector<void*> buffers;
buffers.resize(buffer_count);
std::vector<std::thread> threads;
threads.resize(thread_count);
void* reference = allocator->Alloc(buffer_size);
std::memset(reference, 0xaa, buffer_size);
auto func = [&buffers, allocator, buffer_size, buffer_count, reference, thread_count](int thread_id) {
for (int i = thread_id; i < buffer_count; i+= thread_count) {
buffers[i] = allocator->Alloc(buffer_size);
std::memcpy(buffers[i], reference, buffer_size);
allocator->Free(buffers[i]);
}
};
for (int i = 0; i < 10; i++)
{
std::chrono::high_resolution_clock::time_point t1 = std::chrono::high_resolution_clock::now();
for (int t = 0; t < thread_count; t++) {
threads[t] = std::thread(func, t);
}
for (int t = 0; t < thread_count; t++) {
threads[t].join();
}
std::chrono::high_resolution_clock::time_point t2 = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(t2 - t1).count();
std::cout << duration << std::endl;
}
DebugBreak();
return 0;
}
在所有理智的条件下,BasicAlloc
更快,这是应该的。事实上,在四核 CPU(无 HT)上,没有 ThreadAlloc
可以胜过它的星座。 ThreadAlloc
总是慢 30% 左右。 (这实际上是非常少的,即使对于很小的 1kB 分配也是如此!)
但是,如果 CPU 有大约 8-12 个虚拟核心,那么它最终会达到 BasicAlloc
实际上负扩展的程度,而 ThreadAlloc
只是 "stalls"在软故障的基线开销上。
如果分析这两种不同的分配策略,您会发现对于低线程数,KiPageFault
从 BasicAlloc
的 memcpy
转变为 VirtualLock
ThreadAlloc
.
对于更高的线程和内核数量,最终 ExpWaitForSpinLockExclusiveAndAcquire
开始从几乎零负载上升到 BasicAlloc
的 50%,而 ThreadAlloc
仅保持 [= 的恒定开销16=]本身。
嗯,跟ThreadAlloc
的摊子也很烂。无论您在 NUMA 系统中有多少个内核或节点,目前在系统中的所有进程中,您的新分配都很难达到 5-8GB/s 左右,仅受单线程性能的限制。所有专用内存管理线程都实现了,不会在争用的临界区上浪费 CPU 个周期。
您可能会期望 Microsoft 有一个无锁策略来在不同的内核上分配页面,但显然事实并非如此。
自旋锁也已经出现在 Windows 7 和更早的 KiPageFault
实现中。那么改变了什么?
简单的回答:KiPageFault
本身变慢了很多。不知道到底是什么导致它变慢了,但自旋锁从来没有成为一个明显的限制,因为 100% 的争用以前是不可能的。
如果有人想拆解 KiPageFault
以找到最昂贵的部件 - 请来客。
Microsoft 似乎已经通过 Windows 10 Fall Creators Update 和 Windows 10 Pro for Workstation 解决了这个问题。
这是更新后的图表。
Win 10 FCU 和 WKS 的开销比 Win 7 低。作为交换,VirtualLock 似乎有更高的开销。
我们设置了两个具有以下规格的相同 HP Z840 工作站
- 2 x Xeon E5-2690 v4 @ 2.60GHz(Turbo Boost 开启,HT 关闭,总共 28 个逻辑 CPU)
- 32GB DDR4 2400 内存,四通道
并安装了 Windows 7 SP1 (x64) 和 Windows 10 Creators Update (x64)。
然后我们 运行 一个小型内存基准测试(下面的代码,使用 VS2015 Update 3,64 位架构构建),它从多个线程同时执行内存分配 - 无填充。
#include <Windows.h>
#include <vector>
#include <ppl.h>
unsigned __int64 ZQueryPerformanceCounter()
{
unsigned __int64 c;
::QueryPerformanceCounter((LARGE_INTEGER *)&c);
return c;
}
unsigned __int64 ZQueryPerformanceFrequency()
{
unsigned __int64 c;
::QueryPerformanceFrequency((LARGE_INTEGER *)&c);
return c;
}
class CZPerfCounter {
public:
CZPerfCounter() : m_st(ZQueryPerformanceCounter()) {};
void reset() { m_st = ZQueryPerformanceCounter(); };
unsigned __int64 elapsedCount() { return ZQueryPerformanceCounter() - m_st; };
unsigned long elapsedMS() { return (unsigned long)(elapsedCount() * 1000 / m_freq); };
unsigned long elapsedMicroSec() { return (unsigned long)(elapsedCount() * 1000 * 1000 / m_freq); };
static unsigned __int64 frequency() { return m_freq; };
private:
unsigned __int64 m_st;
static unsigned __int64 m_freq;
};
unsigned __int64 CZPerfCounter::m_freq = ZQueryPerformanceFrequency();
int main(int argc, char ** argv)
{
SYSTEM_INFO sysinfo;
GetSystemInfo(&sysinfo);
int ncpu = sysinfo.dwNumberOfProcessors;
if (argc == 2) {
ncpu = atoi(argv[1]);
}
{
printf("No of threads %d\n", ncpu);
try {
concurrency::Scheduler::ResetDefaultSchedulerPolicy();
int min_threads = 1;
int max_threads = ncpu;
concurrency::SchedulerPolicy policy
(2 // two entries of policy settings
, concurrency::MinConcurrency, min_threads
, concurrency::MaxConcurrency, max_threads
);
concurrency::Scheduler::SetDefaultSchedulerPolicy(policy);
}
catch (concurrency::default_scheduler_exists &) {
printf("Cannot set concurrency runtime scheduler policy (Default scheduler already exists).\n");
}
static int cnt = 100;
static int num_fills = 1;
CZPerfCounter pcTotal;
// malloc/free
printf("malloc/free\n");
{
CZPerfCounter pc;
for (int i = 1 * 1024 * 1024; i <= 8 * 1024 * 1024; i *= 2) {
concurrency::parallel_for(0, 50, [i](size_t x) {
std::vector<void *> ptrs;
ptrs.reserve(cnt);
for (int n = 0; n < cnt; n++) {
auto p = malloc(i);
ptrs.emplace_back(p);
}
for (int x = 0; x < num_fills; x++) {
for (auto p : ptrs) {
memset(p, num_fills, i);
}
}
for (auto p : ptrs) {
free(p);
}
});
printf("size %4d MB, elapsed %8.2f s, \n", i / (1024 * 1024), pc.elapsedMS() / 1000.0);
pc.reset();
}
}
printf("\n");
printf("Total %6.2f s\n", pcTotal.elapsedMS() / 1000.0);
}
return 0;
}
令人惊讶的是,与 Windows 7 相比,Windows 10 CU 的结果非常糟糕。我在下面绘制了 1MB 块大小和 8MB 块大小的结果,线程数从 2 ,4,.., 最多 28。当我们增加线程数时,Windows 7 的性能稍差,而 Windows 10 的可扩展性更差。
我们已尝试确保应用所有 Windows 更新、更新驱动程序、调整 BIOS 设置,但均未成功。我们也在其他几个硬件平台上运行做了同样的benchmark,都给了Windows10相似的曲线。看来是Windows10的问题。
有没有人有类似的经验,或者知道这方面的知识(也许我们错过了什么?)。这种行为使我们的多线程应用程序受到了显着的性能影响。
*** 已编辑
使用https://github.com/google/UIforETW(感谢Bruce Dawson)分析基准,我们发现大部分时间都花在了内核KiPageFault 内部。进一步深入调用树,所有这些都会导致 ExpWaitForSpinLockExclusiveAndAcquire。似乎是锁争用导致了这个问题。
*** 已编辑
在同一硬件上收集 Server 2012 R2 数据。 Server 2012 R2 也比 Win7 差,但还是比 Win10 CU 好很多。
*** 已编辑
它也发生在 Server 2016 中。我添加了标签 windows-server-2016.
*** 已编辑
使用来自@Ext3h 的信息,我修改了基准以使用 VirtualAlloc 和 VirtualLock。与不使用 VirtualLock 时相比,我可以确认有显着改进。总体而言,Win10 在同时使用 VirtualAlloc 和 VirtualLock 时仍然比 Win7 慢 30% 到 40%。
很遗憾,这不是答案,只是一些额外的见解。
使用不同分配策略的小实验:
#include <Windows.h>
#include <thread>
#include <condition_variable>
#include <mutex>
#include <queue>
#include <atomic>
#include <iostream>
#include <chrono>
class AllocTest
{
public:
virtual void* Alloc(size_t size) = 0;
virtual void Free(void* allocation) = 0;
};
class BasicAlloc : public AllocTest
{
public:
void* Alloc(size_t size) override {
return VirtualAlloc(NULL, size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
}
void Free(void* allocation) override {
VirtualFree(allocation, NULL, MEM_RELEASE);
}
};
class ThreadAlloc : public AllocTest
{
public:
ThreadAlloc() {
t = std::thread([this]() {
std::unique_lock<std::mutex> qlock(this->qm);
do {
this->qcv.wait(qlock, [this]() {
return shutdown || !q.empty();
});
{
std::unique_lock<std::mutex> rlock(this->rm);
while (!q.empty())
{
q.front()();
q.pop();
}
}
rcv.notify_all();
} while (!shutdown);
});
}
~ThreadAlloc() {
{
std::unique_lock<std::mutex> lock1(this->rm);
std::unique_lock<std::mutex> lock2(this->qm);
shutdown = true;
}
qcv.notify_all();
rcv.notify_all();
t.join();
}
void* Alloc(size_t size) override {
void* target = nullptr;
{
std::unique_lock<std::mutex> lock(this->qm);
q.emplace([this, &target, size]() {
target = VirtualAlloc(NULL, size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
VirtualLock(target, size);
VirtualUnlock(target, size);
});
}
qcv.notify_one();
{
std::unique_lock<std::mutex> lock(this->rm);
rcv.wait(lock, [&target]() {
return target != nullptr;
});
}
return target;
}
void Free(void* allocation) override {
{
std::unique_lock<std::mutex> lock(this->qm);
q.emplace([allocation]() {
VirtualFree(allocation, NULL, MEM_RELEASE);
});
}
qcv.notify_one();
}
private:
std::queue<std::function<void()>> q;
std::condition_variable qcv;
std::condition_variable rcv;
std::mutex qm;
std::mutex rm;
std::thread t;
std::atomic_bool shutdown = false;
};
int main()
{
SetProcessWorkingSetSize(GetCurrentProcess(), size_t(4) * 1024 * 1024 * 1024, size_t(16) * 1024 * 1024 * 1024);
BasicAlloc alloc1;
ThreadAlloc alloc2;
AllocTest *allocator = &alloc2;
const size_t buffer_size =1*1024*1024;
const size_t buffer_count = 10*1024;
const unsigned int thread_count = 32;
std::vector<void*> buffers;
buffers.resize(buffer_count);
std::vector<std::thread> threads;
threads.resize(thread_count);
void* reference = allocator->Alloc(buffer_size);
std::memset(reference, 0xaa, buffer_size);
auto func = [&buffers, allocator, buffer_size, buffer_count, reference, thread_count](int thread_id) {
for (int i = thread_id; i < buffer_count; i+= thread_count) {
buffers[i] = allocator->Alloc(buffer_size);
std::memcpy(buffers[i], reference, buffer_size);
allocator->Free(buffers[i]);
}
};
for (int i = 0; i < 10; i++)
{
std::chrono::high_resolution_clock::time_point t1 = std::chrono::high_resolution_clock::now();
for (int t = 0; t < thread_count; t++) {
threads[t] = std::thread(func, t);
}
for (int t = 0; t < thread_count; t++) {
threads[t].join();
}
std::chrono::high_resolution_clock::time_point t2 = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(t2 - t1).count();
std::cout << duration << std::endl;
}
DebugBreak();
return 0;
}
在所有理智的条件下,BasicAlloc
更快,这是应该的。事实上,在四核 CPU(无 HT)上,没有 ThreadAlloc
可以胜过它的星座。 ThreadAlloc
总是慢 30% 左右。 (这实际上是非常少的,即使对于很小的 1kB 分配也是如此!)
但是,如果 CPU 有大约 8-12 个虚拟核心,那么它最终会达到 BasicAlloc
实际上负扩展的程度,而 ThreadAlloc
只是 "stalls"在软故障的基线开销上。
如果分析这两种不同的分配策略,您会发现对于低线程数,KiPageFault
从 BasicAlloc
的 memcpy
转变为 VirtualLock
ThreadAlloc
.
对于更高的线程和内核数量,最终 ExpWaitForSpinLockExclusiveAndAcquire
开始从几乎零负载上升到 BasicAlloc
的 50%,而 ThreadAlloc
仅保持 [= 的恒定开销16=]本身。
嗯,跟ThreadAlloc
的摊子也很烂。无论您在 NUMA 系统中有多少个内核或节点,目前在系统中的所有进程中,您的新分配都很难达到 5-8GB/s 左右,仅受单线程性能的限制。所有专用内存管理线程都实现了,不会在争用的临界区上浪费 CPU 个周期。
您可能会期望 Microsoft 有一个无锁策略来在不同的内核上分配页面,但显然事实并非如此。
自旋锁也已经出现在 Windows 7 和更早的 KiPageFault
实现中。那么改变了什么?
简单的回答:KiPageFault
本身变慢了很多。不知道到底是什么导致它变慢了,但自旋锁从来没有成为一个明显的限制,因为 100% 的争用以前是不可能的。
如果有人想拆解 KiPageFault
以找到最昂贵的部件 - 请来客。
Microsoft 似乎已经通过 Windows 10 Fall Creators Update 和 Windows 10 Pro for Workstation 解决了这个问题。
这是更新后的图表。
Win 10 FCU 和 WKS 的开销比 Win 7 低。作为交换,VirtualLock 似乎有更高的开销。