webdevqa.jp.net

Windows 7と比較したWindows 10のパフォーマンスの低下(ページフォールトの処理はスケーラブルではなく、スレッド数が16を超えると深刻なロック競合が発生します)

2つの同一のHP Z840ワークステーションを以下の仕様でセットアップしました

  • 2 x Xeon E5-2690 v4 @ 2.60GHz(ターボブーストオン、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 10ではスケーラビリティが大幅に低下しました。

Windows 10 memory access is not scalable

すべてのWindows更新プログラムが適用されていることを確認し、ドライバーを更新し、BIOS設定を調整しましたが、成功しませんでした。また、他のいくつかのハードウェアプラットフォームで同じベンチマークを実行し、すべてがWindows 10で同様の曲線を示しました。したがって、Windows 10の問題のようです。

誰もが同様の経験を持っていますか、これについてのノウハウがありますか(何か見逃したかもしれません)。この動作により、マルチスレッドアプリケーションのパフォーマンスが大幅に低下しました。

***編集済み

https://github.com/google/UIforETW (Bruce Dawsonに感謝)を使用してベンチマークを分析すると、ほとんどの時間はカーネルKiPageFault内で費やされていることがわかりました。呼び出しツリーをさらに掘り下げると、すべてがExpWaitForSpinLockExclusiveAndAcquireにつながります。ロックの競合がこの問題を引き起こしているようです。

enter image description here

***編集済み

同じハードウェアで収集されたServer 2012 R2データ。 Server 2012 R2はWin7よりも劣りますが、それでもWin10 CUよりははるかに優れています。

enter image description here

***編集済み

Server 2016でも同様です。タグwindows-server-2016を追加しました。

***編集済み

@ Ext3hからの情報を使用して、VirtualAllocおよびVirtualLockを使用するようにベンチマークを変更しました。 VirtualLockを使用しない場合と比較して、大幅な改善が確認できました。 VirtualAllocとVirtualLockの両方を使用する場合、Win10全体はWin7よりも30〜40%遅くなります。

enter image description here

29
nikoniko

Microsoftは、Windows 10 Fall Creators UpdateおよびWindows 10 Pro for Workstationでこの問題を修正したようです。

これが更新されたグラフです。

enter image description here

Win 10 FCUおよびWKSのオーバーヘッドはWin 7よりも低くなります。代わりに、VirtualLockのオーバーヘッドは高くなるようです。

8
nikoniko

残念ながら答えではなく、いくつかの追加の洞察。

別の割り当て戦略での小さな実験:

#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はソフトフォールトのベースラインオーバーヘッドで「停止」します。

2つの異なる割り当て戦略をプロファイルすると、スレッド数が少ない場合、KiPageFaultmemcpyBasicAllocからVirtualLockThreadAllocにシフトすることがわかります。

スレッド数とコア数を増やすと、最終的にExpWaitForSpinLockExclusiveAndAcquireは、BasicAllocで実質的にゼロの負荷から最大50%になり始めますが、ThreadAllocは、KiPageFaultからの一定のオーバーヘッドのみを維持します。

まあ、ThreadAllocのストールもかなり悪いです。 NUMAシステム内のコアまたはノードの数に関係なく、現在、システム内のすべてのプロセスで、シングルスレッドのパフォーマンスによってのみ制限される、新しい割り当てで約5〜8 GB /秒にハードキャップされています。専用のメモリ管理スレッドはすべて、競合するクリティカルセクションでCPUサイクルを浪費することはありません。

Microsoftが異なるコアのページを割り当てるためのロックフリー戦略を持っていることを期待していましたが、どうやらそれはリモートでもそうではないようです。


スピンロックは、Windows 7以前のKiPageFaultの実装にも既に存在していました。それで、何が変わったのですか?

簡単な答え:KiPageFault自体はずっと遅くなりました。何が原因で速度が低下したのかはわかりませんが、スピンロックは決して明白な制限にはなりませんでした。これまで100%の競合は不可能だったからです。

誰かがKiPageFaultを分解して最も高価な部分を見つけるためにささやくなら-私のゲストになってください。

4
Ext3h