Подтвердить что ты не робот

Низкая производительность Windows 10 по сравнению с Windows 7 (обработка ошибок страниц не является масштабируемой, серьезной блокировкой при отсутствии потоков> 16)

Мы установили две идентичные рабочие станции HP Z840 со следующими спецификациями

  • 2 x Xeon E5-2690 v4 @2.60GHz (Turbo Boost ON, HT OFF, всего 28 логических процессоров)
  • 32 ГБ памяти 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 10 CU результат очень плохой, по сравнению с Windows 7. Я построил результат ниже для размера блока 1 МБ и размера блока 8 МБ, изменяя количество потоков от 2,4,.. до 28. Хотя Windows 7 давала немного худшую производительность, когда мы увеличивали количество потоков, Windows 10 давала гораздо худшую масштабируемость.

Доступ к памяти Windows 10 не масштабируется

Мы постарались убедиться, что все обновления Windows применяются, обновляют драйверы, настраивают настройки BIOS без успеха. Мы также использовали тот же бенчмарк на нескольких других аппаратных платформах, и все они дали аналогичную кривую для Windows 10. Так что это проблема Windows 10.

Есть ли у кого-то подобный опыт или, может быть, ноу-хау (возможно, мы что-то пропустили?). Такое поведение заставило наше многопоточное приложение получить значительный успех.

*** EDITED

Используя https://github.com/google/UIforETW (спасибо Брюсу Доусону), чтобы проанализировать бенчмарк, мы обнаружили, что большую часть времени тратится в ядрах KiPageFault. Копая дальше по дереву вызовов, все приводит к ExpWaitForSpinLockExclusiveAndAcquire. Кажется, что проблема блокировки вызывает эту проблему.

введите описание изображения здесь

*** EDITED

Собранный сервер 2012 R2 данные на одном оборудовании. Сервер 2012 R2 также хуже Win7, но все же намного лучше, чем Win10 CU.

введите описание изображения здесь

*** EDITED

Это происходит и на сервере 2016. Я добавил тег windows-server-2016.

*** EDITED

Используя информацию из @Ext3h, я изменил эталон для использования VirtualAlloc и VirtualLock. Я могу подтвердить значительное улучшение по сравнению с тем, когда VirtualLock не используется. Общий Win10 по-прежнему на 30-40% медленнее, чем Win7, когда оба используют VirtualAlloc и VirtualLock.

введите описание изображения здесь

4b9b3361

Ответ 1

Microsoft, похоже, исправила эту проблему с помощью Windows 10 Fall Creators Update и Windows 10 Pro для рабочей станции.

Вот обновленный график.

введите описание изображения здесь

Win 10 FCU и WKS имеют меньшие накладные расходы, чем Win 7. Взамен VirtualLock имеет более высокие накладные расходы.

Ответ 2

К сожалению, не ответ, просто дополнительная информация.

Маленький эксперимент с другой стратегией распределения:

#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 работает быстрее, как и должно быть. Фактически, на четырехъядерном процессоре (без HT) нет созвездия, в котором ThreadAlloc может превзойти его. ThreadAlloc постоянно на 30% медленнее. (Что на самом деле удивительно мало, и оно сохраняется даже для небольших распределений 1 кБ!)

Однако, если процессор имеет около 8-12 виртуальных ядер, то он в конечном итоге достигает точки, где BasicAlloc фактически масштабируется отрицательно, а ThreadAlloc просто "ломается" на основе служебных сообщений базовой линии от мягких ошибок.

Если вы профилируете две разные стратегии распределения, вы можете увидеть, что для низкого количества потоков KiPageFault сдвигается с memcpy на BasicAlloc до VirtualLock на ThreadAlloc.

Для более высоких показателей потока и ядра в конечном итоге ExpWaitForSpinLockExclusiveAndAcquire начинает с практически нулевой нагрузки до 50% с BasicAlloc, тогда как ThreadAlloc поддерживает только постоянные служебные данные от KiPageFault.

Ну, стойло с ThreadAlloc тоже довольно плохое. Независимо от того, сколько ядер или узлов в системе NUMA у вас есть, вы в настоящее время сильно ограничены примерно 5-8 Гбайт/с в новых распределениях, во всех процессах в системе, ограничиваясь только одним потоком производительности. Все выделенные потоки управления памятью не теряют процессорных циклов в критическом разделе.

Вы бы ожидали, что у Microsoft была свободная от блокировки стратегия для назначения страниц на разных ядрах, но, по-видимому, это даже не отдаленно.


Спин-блокировка также присутствовала в версиях Windows 7 и более ранних версий KiPageFault. Итак, что изменилось?

Простой ответ: KiPageFault сам стал намного медленнее. Не знаю, что именно заставило его замедлиться, но спин-блокировка просто никогда не становилась очевидным пределом, потому что 100% -ый спор никогда не был возможен раньше.

Если кто-то хочет разобрать KiPageFault, чтобы найти самую дорогую часть - будь моим гостем.