Nous avons installé deux postes de travail HP Z840 identiques avec les spécifications suivantes
et installé Windows 7 SP1 (x64) et Windows 10 Creators Update (x64) sur chacun.
Ensuite, nous avons exécuté un petit test de mémoire (code ci-dessous, construit avec VS2015 Update 3, architecture 64 bits) qui effectue simultanément l'allocation de mémoire sans remplissage à partir de plusieurs threads.
#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;
}
Étonnamment, le résultat est très mauvais dans Windows 10 CU par rapport à Windows 7. J'ai tracé le résultat ci-dessous pour la taille de bloc de 1 Mo et la taille de bloc de 8 Mo, variant le nombre de threads de 2,4, .., jusqu'à 28. Alors que Windows 7 a donné des performances légèrement inférieures lorsque nous avons augmenté le nombre de threads, Windows 10 a donné une évolutivité bien pire.
Nous avons essayé de nous assurer que toutes les mises à jour Windows sont appliquées, mise à jour des pilotes, réglages du BIOS Tweak, sans succès. Nous avons également exécuté le même benchmark sur plusieurs autres plates-formes matérielles, et toutes ont donné une courbe similaire pour Windows 10. Il semble donc que ce soit un problème de Windows 10.
Quelqu'un at-il une expérience similaire, ou peut-être un savoir-faire à ce sujet (peut-être que nous avons manqué quelque chose?). Ce comportement a permis à notre application multithread d'obtenir des performances significatives.
*** ÉDITÉ
En utilisant https://github.com/google/UIforETW (merci à Bruce Dawson) pour analyser le benchmark, nous avons constaté que la plupart du temps est passé à l'intérieur des noyaux KiPageFault. En creusant plus loin dans l'arborescence des appels, tout mène à ExpWaitForSpinLockExclusiveAndAcquire. Semble que le conflit de verrouillage est à l'origine de ce problème.
*** ÉDITÉ
Données de Server 2012 R2 collectées sur le même matériel. Server 2012 R2 est également pire que Win7, mais toujours bien meilleur que Win10 CU.
*** ÉDITÉ
Cela se produit également dans Server 2016. J'ai ajouté la balise windows-server-2016.
*** ÉDITÉ
En utilisant les informations de @ Ext3h, j'ai modifié le benchmark pour utiliser VirtualAlloc et VirtualLock. Je peux confirmer une amélioration significative par rapport à l'utilisation de VirtualLock. Globalement, Win10 est toujours 30% à 40% plus lent que Win7 lors de l'utilisation de VirtualAlloc et VirtualLock.
Malheureusement pas une réponse, juste un aperçu supplémentaire.
Petite expérience avec une stratégie d'allocation différente:
#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;
}
Dans toutes les conditions raisonnables, BasicAlloc
est plus rapide, comme il se doit. En fait, sur un processeur quad core (pas de HT), il n'y a pas de constellation dans laquelle ThreadAlloc
pourrait le surpasser. ThreadAlloc
est constamment plus lent d'environ 30%. (Ce qui est en fait étonnamment peu, et cela reste vrai même pour les petites allocations de 1 Ko!)
Cependant, si le CPU a environ 8 à 12 cœurs virtuels, il atteint finalement le point où BasicAlloc
évolue réellement de manière négative, tandis que ThreadAlloc
se "bloque" simplement sur la ligne de base au-dessus des erreurs logicielles.
Si vous profilez les deux stratégies d'allocation différentes, vous pouvez voir que pour un faible nombre de threads, KiPageFault
passe de memcpy
sur BasicAlloc
à VirtualLock
sur ThreadAlloc
.
Pour un nombre de threads et de noyaux plus élevé, ExpWaitForSpinLockExclusiveAndAcquire
finit par émerger d'une charge pratiquement nulle jusqu'à 50% avec BasicAlloc
, tandis que ThreadAlloc
ne maintient que la surcharge constante de KiPageFault
lui-même.
Eh bien, le décrochage avec ThreadAlloc
est également assez mauvais. Quel que soit le nombre de cœurs ou de nœuds dans un système NUMA, vous êtes actuellement limité à environ 5-8 Go / s dans les nouvelles allocations, dans tous les processus du système, uniquement limité par les performances d'un seul thread. Tout le fil de gestion de mémoire dédié atteint, ne gaspille pas les cycles CPU sur une section critique contestée.
Vous vous attendiez à ce que Microsoft ait une stratégie sans verrouillage pour attribuer des pages sur différents cœurs, mais apparemment, ce n'est même pas le cas à distance.
Le verrou rotatif était également déjà présent dans Windows 7 et les implémentations antérieures de KiPageFault
. Alors qu'est-ce qui a changé?
Réponse simple: KiPageFault
lui-même est devenu beaucoup plus lent. Aucune idée de ce qui a exactement causé son ralentissement, mais le verrou rotatif n'est tout simplement jamais devenu une limite évidente, car une contention à 100% n'était jamais possible auparavant.
Si quelqu'un souhaite démonter KiPageFault
pour trouver la pièce la plus chère, soyez mon invité.