与Windows 7相比,Windows 10性能较差(页面error handling不可伸缩,当线程数大于16时严重的锁争用)

我们设置了两个完全相同的HP Z840工作站,并具有以下规格

  • 2 x Xeon E5-2690 v4 @ 2.60GHz(Turbo Boost ON,HT OFF,总共28个逻辑CPU)
  • 32GB DDR4 2400内存,四通道

并在其上安装了Windows 7 SP1(x64)和Windows 10 Creators Update(x64)。

然后我们运行了一个小内存基准testing(下面的代码,使用VS2015 Update 3,64位体系结构构build),它可以同时从多个线程执行内存分配 – 无填充。

#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.2fs, \n", i / (1024 * 1024), pc.elapsedMS() / 1000.0); pc.reset(); } } printf("\n"); printf("Total %6.2fs\n", pcTotal.elapsedMS() / 1000.0); } return 0; } 

令人惊讶的是,与Windows 7相比,Windows 10 CU的结果非常糟糕。我绘制了1MB块大小和8MB块大小以下的结果,将线程数从2,4,…,到28不等。而Windows 7当我们增加线程数时,性能稍差,Windows 10的可扩展性差得多。

Windows 10内存访问不可扩展

我们已经尝试确保所有的Windows更新都被应用,更新驱动程序,调整BIOS设置,但没有成功。 我们还在其他几个硬件平台上运行了相同的基准testing,并且都给出了与Windows 10类似的曲线。所以这似乎是Windows 10的一个问题。

有没有人有类似的经验,或者可能知道这个(也许我们错过了什么?)。 这种行为使得我们的multithreading应用程序获得了显着的性能。

***编辑

使用https://github.com/google/UIforETW (感谢Bruce Dawson)来分析基准,我们发现大部分时间都花在内核KiPageFault中。 进一步深入调用树,所有导致ExpWaitForSpinLockExclusiveAndAcquire。 似乎锁争用导致了这个问题。

在这里输入图像说明

***编辑

在同一硬件上收集服务器2012 R2数据。 Server 2012 R2也比Win7差,但仍比Win10 CU好很多。

在这里输入图像说明

***编辑

它也发生在服务器2016年。 我添加了标签windows-server-2016。

***编辑

使用来自@Ext3h的信息,我将基准修改为使用VirtualAlloc和VirtualLock。 与不使用VirtualLock的情况相比,我可以证实有明显的改进。 当使用VirtualAlloc和VirtualLock时,整体Win10比Win7慢30%到40%。

在这里输入图像说明

微软似乎已经解决了Windows 10 Fall Creators Update和Windows 10 Pro for Workstation的这个问题。

这是更新的图表。

在这里输入图像说明

Win 10 FCU和WKS的开销比Win 7低。作为交换,VirtualLock似乎有更高的开销。

不幸的是没有答案,只是一些额外的见解。

一个不同的分配策略的小实验:

 #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只是在软故障的基线开销“停顿”。

如果分析两种不同的分配策略,可以看到对于低线程数, KiPageFaultBasicAlloc上的memcpy转换为BasicAlloc上的VirtualLock

对于更高的线程和内核数量,最终ExpWaitForSpinLockExclusiveAndAcquire开始从几乎零加载到BasicAlloc高达50%,而ThreadAlloc只维护KiPageFault本身的常量开销。

那么,与ThreadAlloc的摊位也非常糟糕。 无论您的NUMA系统中有多少个核心或节点,在系统中的所有进程中,您目前都被限制在大约5-8GB / s的新分配,完全受单线程性能限制。 所有的专用内存管理线程都达到了,不会浪费在竞争关键部分的CPU周期。

你可能会预料到,微软在不同核心上分配页面的策略是无锁的,但显然这并不是那么简单。


自旋锁也已经出现在Windows 7和早期的KiPageFault实现中。 那么改变了什么?

简单的答案: KiPageFault本身变得更慢。 不知道究竟是什么造成了减速,但自旋锁从来没有成为一个明显的限制,因为100%的争夺以前是不可能的。

如果有人希望拆卸KiPageFault以找到最昂贵的部分 – 做我的客人。