Назад | Перейти на главную страницу

Пропускная способность Memcpy ~ в 1,6 раза быстрее на 1 или 2 сокета Intel Scalable (Skylake)?

Я сейчас портирую сложное приложение, ориентированное на производительность, для работы на новой машине с двумя сокетами. При этом я столкнулся с некоторыми аномалиями производительности и после долгих экспериментов обнаружил, что пропускная способность памяти на новой машине кажется значительно ниже, чем я ожидал.

В машине используется Supermicro X11DGQ материнская плата с 2 X Intel Xeon Gold 6148 процессоры и 6 x 32 ГБ оперативной памяти DDR4-2133 (всего 192 ГБ). Система работает под управлением Ubuntu 16.04.4 с ядром 4.13.

Я написал простую утилиту для проверки памяти, которая многократно запускается и memcpy для определения средней продолжительности и скорости:

#include <algorithm>
#include <chrono>
#include <cstring>
#include <iomanip>
#include <iostream>

#include <unistd.h>

const uint64_t MB_SCALER = 1024 * 1024L;

// g++ -std=c++11 -O3 -march=native -o mem_test mem_test.cc
int main(int argc, char** argv)
{
    uint64_t buffer_size = 64 * MB_SCALER;
    uint32_t num_loops = 100;

    std::cout << "Memory Tester\n" << std::endl;

    if (argc < 2)
    {
        std::cout << "Using default values.\n" << std::endl;
    }

    // Parse buffer size
    if (argc >= 2)
    {
        buffer_size = std::strtoul(argv[1], nullptr, 10) * MB_SCALER;
    }

    // Parse num loops 
    if (argc >= 3)
    {
        num_loops = std::strtoul(argv[2], nullptr, 10);
    }

    std::cout << "    Num loops:   " << num_loops << std::endl;
    std::cout << "    Buffer size: " << (buffer_size / MB_SCALER) << " MB" 
              << std::endl;

    // Allocate buffers
    char* buffer1 = nullptr;
    posix_memalign((void**)&buffer1, getpagesize(), buffer_size);
    std::memset(buffer1, 0x5A, buffer_size);

    char* buffer2 = nullptr;
    posix_memalign((void**)&buffer2, getpagesize(), buffer_size);
    std::memset(buffer2, 0xC3, buffer_size);

    // Loop and copy memory, measuring duration each time
    double average_duration = 0;    
    for (uint32_t loop_idx = 0; loop_idx < num_loops; ++loop_idx)
    {    
        auto iter_start = std::chrono::system_clock::now();

        std::memcpy(buffer2, buffer1, buffer_size);

        auto iter_end = std::chrono::system_clock::now();

        // Calculate and accumulate duration
        auto diff = iter_end - iter_start;
        auto duration = std::chrono::duration<double, std::milli>(diff).count();
        average_duration += duration;
    }

    // Calculate and display average duration
    average_duration /= num_loops;
    std::cout << "    Duration:    " << std::setprecision(4) << std::fixed 
              << average_duration << " ms" << std::endl;

    // Calculate and display rate
    double rate = (buffer_size /  MB_SCALER) / (average_duration / 1000);
    std::cout << "    Rate:        " << std::setprecision(2) << std::fixed 
              << rate << " MB/s" << std::endl;

    std::free(buffer1);
    std::free(buffer2);
}

Затем я скомпилировал и запустил эту утилиту, используя размер буфера 64 МБ (значительно больше, чем размер кэша L3) в течение 10 000 циклов.

Конфигурация с двумя сокетами:

$ ./mem_test 64 10000
Memory Tester

    Num loops:   10000
    Buffer size: 64 MB
    Duration:    17.9141 ms
    Rate:        3572.61 MB/s

Конфигурация с одним разъемом:

(такое же оборудование с одним физически удаленным процессором)

#./mem_test 64 10000
Memory Tester

    Num loops:   10000
    Buffer size: 64 MB
    Duration:    11.2055 ms
    Rate:        5711.46 MB/s

Двойная розетка с использованием numactl:

По просьбе коллеги я попытался запустить ту же утилиту, используя numactl локализовать доступ к памяти только для первого узла numa.

$ numactl -m 0 -N 0 ./mem_test 64 10000
Memory Tester

    Num loops:   10000
    Buffer size: 64 MB
    Duration:    18.3539 ms
    Rate:        3486.99 MB/s

Полученные результаты

5711.43 / 3572.61 = 1.59867

Точно такой же тест в обеих конфигурациях показывает, что конфигурация с одним сокетом на ~ 60% быстрее.

я нашел этот вопрос что в чем-то похоже, но гораздо более подробно. Из одного из комментариев: «Заполнение 2-го сокета заставляет даже локальные промахи L3 отслеживать удаленный CPU ...».

Я понимаю концепцию отслеживания L3, но все же накладные расходы по сравнению с одним сокетом кажутся мне невероятно высокими. Ожидается ли поведение, которое я наблюдаю? Может ли кто-нибудь пролить больше света на то, что происходит и что я могу с этим поделать?