Плохая производительность memcpy для Linux

Недавно мы приобрели несколько новых серверов и испытываем плохую производительность memcpy. Производительность memcpy на серверах по сравнению с нашими ноутбуками на 3 раза медленнее.

Характеристики сервера

  • Шасси и Mobo: SUPER MICRO 1027GR-TRF
  • Процессор: 2x Intel Xeon E5-2680 @2.70 Ghz
  • Память: 8x 16 ГБ DDR3 1600 МГц

Изменить: я также тестирую на другом сервере с чуть более высокими спецификациями и вижу те же результаты, что и предыдущий сервер

Характеристики сервера 2

  • Шасси и Mobo: SUPER MICRO 10227GR-TRFT
  • Процессор: 2x Intel Xeon E5-2650 v2 @2.6 Ghz
  • Память: 8x 16GB DDR3 1866MHz

Характеристики ноутбука

  • Корпус: Lenovo W530
  • Процессор: 1x Intel Core i7 i7-3720QM @2.6Ghz
  • Память: 4x 4 ГБ DDR3 1600 МГц

Операционная система

$ cat /etc/redhat-release
Scientific Linux release 6.5 (Carbon) 
$ uname -a                      
Linux r113 2.6.32-431.1.2.el6.x86_64 #1 SMP Thu Dec 12 13:59:19 CST 2013 x86_64 x86_64 x86_64 GNU/Linux

Компилятор (во всех системах)

$ gcc --version
gcc (GCC) 4.6.1

Также тестируется с помощью gcc 4.8.2 на основе предложения от @stefan. Не было разницы в производительности между компиляторами.

Тестовый код Ниже приведен тестовый код - это консервированный тест, чтобы дублировать проблему, которую я вижу в нашем производственном коде. Я знаю, что этот критерий упрощен, но он смог использовать и идентифицировать нашу проблему. Код создает два буфера 1 ГБ и memcpys между ними, синхронизируя вызов memcpy. Вы можете указать размер альтернативных буферов в командной строке, используя:./big_memcpy_test [SIZE_BYTES]

#include <chrono>
#include <cstring>
#include <iostream>
#include <cstdint>

class Timer
{
 public:
  Timer()
      : mStart(),
        mStop()
  {
    update();
  }

  void update()
  {
    mStart = std::chrono::high_resolution_clock::now();
    mStop  = mStart;
  }

  double elapsedMs()
  {
    mStop = std::chrono::high_resolution_clock::now();
    std::chrono::milliseconds elapsed_ms =
        std::chrono::duration_cast<std::chrono::milliseconds>(mStop - mStart);
    return elapsed_ms.count();
  }

 private:
  std::chrono::high_resolution_clock::time_point mStart;
  std::chrono::high_resolution_clock::time_point mStop;
};

std::string formatBytes(std::uint64_t bytes)
{
  static const int num_suffix = 5;
  static const char* suffix[num_suffix] = { "B", "KB", "MB", "GB", "TB" };
  double dbl_s_byte = bytes;
  int i = 0;
  for (; (int)(bytes / 1024.) > 0 && i < num_suffix;
       ++i, bytes /= 1024.)
  {
    dbl_s_byte = bytes / 1024.0;
  }

  const int buf_len = 64;
  char buf[buf_len];

  // use snprintf so there is no buffer overrun
  int res = snprintf(buf, buf_len,"%0.2f%s", dbl_s_byte, suffix[i]);

  // snprintf returns number of characters that would have been written if n had
  //       been sufficiently large, not counting the terminating null character.
  //       if an encoding error occurs, a negative number is returned.
  if (res >= 0)
  {
    return std::string(buf);
  }
  return std::string();
}

void doMemmove(void* pDest, const void* pSource, std::size_t sizeBytes)
{
  memmove(pDest, pSource, sizeBytes);
}

int main(int argc, char* argv[])
{
  std::uint64_t SIZE_BYTES = 1073741824; // 1GB

  if (argc > 1)
  {
    SIZE_BYTES = std::stoull(argv[1]);
    std::cout << "Using buffer size from command line: " << formatBytes(SIZE_BYTES)
              << std::endl;
  }
  else
  {
    std::cout << "To specify a custom buffer size: big_memcpy_test [SIZE_BYTES] \n"
              << "Using built in buffer size: " << formatBytes(SIZE_BYTES)
              << std::endl;
  }


  // big array to use for testing
  char* p_big_array = NULL;

  /////////////
  // malloc 
  {
    Timer timer;

    p_big_array = (char*)malloc(SIZE_BYTES * sizeof(char));
    if (p_big_array == NULL)
    {
      std::cerr << "ERROR: malloc of " << SIZE_BYTES << " returned NULL!"
                << std::endl;
      return 1;
    }

    std::cout << "malloc for " << formatBytes(SIZE_BYTES) << " took "
              << timer.elapsedMs() << "ms"
              << std::endl;
  }

  /////////////
  // memset
  {
    Timer timer;

    // set all data in p_big_array to 0
    memset(p_big_array, 0xF, SIZE_BYTES * sizeof(char));

    double elapsed_ms = timer.elapsedMs();
    std::cout << "memset for " << formatBytes(SIZE_BYTES) << " took "
              << elapsed_ms << "ms "
              << "(" << formatBytes(SIZE_BYTES / (elapsed_ms / 1.0e3)) << " bytes/sec)"
              << std::endl;
  }

  /////////////
  // memcpy 
  {
    char* p_dest_array = (char*)malloc(SIZE_BYTES);
    if (p_dest_array == NULL)
    {
      std::cerr << "ERROR: malloc of " << SIZE_BYTES << " for memcpy test"
                << " returned NULL!"
                << std::endl;
      return 1;
    }
    memset(p_dest_array, 0xF, SIZE_BYTES * sizeof(char));

    // time only the memcpy FROM p_big_array TO p_dest_array
    Timer timer;

    memcpy(p_dest_array, p_big_array, SIZE_BYTES * sizeof(char));

    double elapsed_ms = timer.elapsedMs();
    std::cout << "memcpy for " << formatBytes(SIZE_BYTES) << " took "
              << elapsed_ms << "ms "
              << "(" << formatBytes(SIZE_BYTES / (elapsed_ms / 1.0e3)) << " bytes/sec)"
              << std::endl;

    // cleanup p_dest_array
    free(p_dest_array);
    p_dest_array = NULL;
  }

  /////////////
  // memmove
  {
    char* p_dest_array = (char*)malloc(SIZE_BYTES);
    if (p_dest_array == NULL)
    {
      std::cerr << "ERROR: malloc of " << SIZE_BYTES << " for memmove test"
                << " returned NULL!"
                << std::endl;
      return 1;
    }
    memset(p_dest_array, 0xF, SIZE_BYTES * sizeof(char));

    // time only the memmove FROM p_big_array TO p_dest_array
    Timer timer;

    // memmove(p_dest_array, p_big_array, SIZE_BYTES * sizeof(char));
    doMemmove(p_dest_array, p_big_array, SIZE_BYTES * sizeof(char));

    double elapsed_ms = timer.elapsedMs();
    std::cout << "memmove for " << formatBytes(SIZE_BYTES) << " took "
              << elapsed_ms << "ms "
              << "(" << formatBytes(SIZE_BYTES / (elapsed_ms / 1.0e3)) << " bytes/sec)"
              << std::endl;

    // cleanup p_dest_array
    free(p_dest_array);
    p_dest_array = NULL;
  }


  // cleanup
  free(p_big_array);
  p_big_array = NULL;

  return 0;
}

Файл CMake для сборки

project(big_memcpy_test)
cmake_minimum_required(VERSION 2.4.0)

include_directories(${CMAKE_CURRENT_SOURCE_DIR})

# create verbose makefiles that show each command line as it is issued
set( CMAKE_VERBOSE_MAKEFILE ON CACHE BOOL "Verbose" FORCE )
# release mode
set( CMAKE_BUILD_TYPE Release )
# grab in CXXFLAGS environment variable and append C++11 and -Wall options
set( CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++0x -Wall -march=native -mtune=native" )
message( INFO "CMAKE_CXX_FLAGS = ${CMAKE_CXX_FLAGS}" )

# sources to build
set(big_memcpy_test_SRCS
  main.cpp
)

# create an executable file named "big_memcpy_test" from
# the source files in the variable "big_memcpy_test_SRCS".
add_executable(big_memcpy_test ${big_memcpy_test_SRCS})

Результаты тестирования

Buffer Size: 1GB | malloc (ms) | memset (ms) | memcpy (ms) | NUMA nodes (numactl --hardware)
---------------------------------------------------------------------------------------------
Laptop 1         | 0           | 127         | 113         | 1
Laptop 2         | 0           | 180         | 120         | 1
Server 1         | 0           | 306         | 301         | 2
Server 2         | 0           | 352         | 325         | 2

Как вы можете видеть memcpys и memsets на наших серверах намного медленнее, чем memcpys и memsets на наших ноутбуках.

Различные размеры буфера

Я пробовал буферы от 100 МБ до 5 ГБ с одинаковыми результатами (серверы медленнее, чем у ноутбука)

NUMA Affinity

Я читал о людях, имеющих проблемы с производительностью с NUMA, поэтому я попытался установить близость CPU и памяти, используя numactl, но результаты остались прежними.

Серверное оборудование NUMA

$ numactl --hardware                                                            
available: 2 nodes (0-1)                                                                     
node 0 cpus: 0 1 2 3 4 5 6 7 16 17 18 19 20 21 22 23                                         
node 0 size: 65501 MB                                                                        
node 0 free: 62608 MB                                                                        
node 1 cpus: 8 9 10 11 12 13 14 15 24 25 26 27 28 29 30 31                                   
node 1 size: 65536 MB                                                                        
node 1 free: 63837 MB                                                                        
node distances:                                                                              
node   0   1                                                                                 
  0:  10  21                                                                                 
  1:  21  10 

Оборудование для ноутбуков NUMA

$ numactl --hardware
available: 1 nodes (0)
node 0 cpus: 0 1 2 3 4 5 6 7
node 0 size: 16018 MB
node 0 free: 6622 MB
node distances:
node   0 
  0:  10

Настройка близости NUMA

$ numactl --cpunodebind=0 --membind=0 ./big_memcpy_test

Любая помощь, разрешающая это, очень ценится.

Изменить: параметры GCC

Основываясь на комментариях, я попытался выполнить компиляцию с разными вариантами GCC:

Компиляция с -march и -mtune, установленная в native

g++ -std=c++0x -Wall -march=native -mtune=native -O3 -DNDEBUG -o big_memcpy_test main.cpp 

Результат: Точная производительность (без улучшения)

Компиляция с -O2 вместо -O3

g++ -std=c++0x -Wall -march=native -mtune=native -O2 -DNDEBUG -o big_memcpy_test main.cpp

Результат: Точная производительность (без улучшения)

Изменить: Изменено memset для записи 0xF вместо 0, чтобы избежать NULL-страницы (@SteveCox)

Нет улучшения при установке memset со значением, отличным от 0 (в этом случае используется 0xF).

Изменить: результаты Cachebench

Чтобы исключить, что моя тестовая программа слишком упрощена, я загрузил настоящую программу бенчмаркинга LLCacheBench (http://icl.cs.utk.edu/projects/llcbench/cachebench.html)

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

laptop vs server memcpy performance

Обратите внимание, что ОЧЕНЬ большая разница в производительности при больших размерах буфера. Последний тестируемый размер (16777216) выполнен на 18849,29 МБ/с на ноутбуке и 6710,40 на сервере. Это примерно 3-кратное различие в производительности. Вы также можете заметить, что падение производительности сервера намного круче, чем на ноутбуке.

Изменить: memmove() - 2x FASTER, чем memcpy() на сервере

Основываясь на некоторых экспериментах, я попытался использовать memmove() вместо memcpy() в своем тестовом примере и нашел улучшение на уровне 2x на сервере. Memmove() на ноутбуке работает медленнее, чем memcpy(), но, как ни странно, работает на той же скорости, что и memmove() на сервере. Это задает вопрос, почему memcpy так медленно?

Обновлен код для проверки memmove вместе с memcpy. Мне пришлось обернуть memmove() внутри функции, потому что, если бы я оставил его встроенным GCC, он оптимизировал его и выполнил то же самое, что и memcpy() (я предполагаю, что gcc оптимизировал его для memcpy, потому что знал, что локации не перекрываются).

Обновленные результаты

Buffer Size: 1GB | malloc (ms) | memset (ms) | memcpy (ms) | memmove() | NUMA nodes (numactl --hardware)
---------------------------------------------------------------------------------------------------------
Laptop 1         | 0           | 127         | 113         | 161       | 1
Laptop 2         | 0           | 180         | 120         | 160       | 1
Server 1         | 0           | 306         | 301         | 159       | 2
Server 2         | 0           | 352         | 325         | 159       | 2

Изменить: Наивный Memcpy

Основываясь на предположении от @Salgar, я реализовал свою собственную наивную функцию memcpy и протестировал ее.

Наивный Memcpy Source

void naiveMemcpy(void* pDest, const void* pSource, std::size_t sizeBytes)
{
  char* p_dest = (char*)pDest;
  const char* p_source = (const char*)pSource;
  for (std::size_t i = 0; i < sizeBytes; ++i)
  {
    *p_dest++ = *p_source++;
  }
}

Наивные результаты Memcpy По сравнению с memcpy()

Buffer Size: 1GB | memcpy (ms) | memmove(ms) | naiveMemcpy()
------------------------------------------------------------
Laptop 1         | 113         | 161         | 160
Server 1         | 301         | 159         | 159
Server 2         | 325         | 159         | 159

Изменить: Сборочный вывод

Простой источник memcpy

#include <cstring>
#include <cstdlib>

int main(int argc, char* argv[])
{
  size_t SIZE_BYTES = 1073741824; // 1GB

  char* p_big_array  = (char*)malloc(SIZE_BYTES * sizeof(char));
  char* p_dest_array = (char*)malloc(SIZE_BYTES * sizeof(char));

  memset(p_big_array,  0xA, SIZE_BYTES * sizeof(char));
  memset(p_dest_array, 0xF, SIZE_BYTES * sizeof(char));

  memcpy(p_dest_array, p_big_array, SIZE_BYTES * sizeof(char));

  free(p_dest_array);
  free(p_big_array);

  return 0;
}

Сборка: это то же самое как на сервере, так и на ноутбуке. Я экономя пространство, а не вставляя оба.

        .file   "main_memcpy.cpp"
        .section        .text.startup,"ax",@progbits
        .p2align 4,,15
        .globl  main
        .type   main, @function
main:
.LFB25:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movl    $1073741824, %edi
        pushq   %rbx
        .cfi_def_cfa_offset 24
        .cfi_offset 3, -24
        subq    $8, %rsp
        .cfi_def_cfa_offset 32
        call    malloc
        movl    $1073741824, %edi
        movq    %rax, %rbx
        call    malloc
        movl    $1073741824, %edx
        movq    %rax, %rbp
        movl    $10, %esi
        movq    %rbx, %rdi
        call    memset
        movl    $1073741824, %edx
        movl    $15, %esi
        movq    %rbp, %rdi
        call    memset
        movl    $1073741824, %edx
        movq    %rbx, %rsi
        movq    %rbp, %rdi
        call    memcpy
        movq    %rbp, %rdi
        call    free
        movq    %rbx, %rdi
        call    free
        addq    $8, %rsp
        .cfi_def_cfa_offset 24
        xorl    %eax, %eax
        popq    %rbx
        .cfi_def_cfa_offset 16
        popq    %rbp
        .cfi_def_cfa_offset 8
        ret
        .cfi_endproc
.LFE25:
        .size   main, .-main
        .ident  "GCC: (GNU) 4.6.1"
        .section        .note.GNU-stack,"",@progbits

PROGRESS!!!! ASMlib

Основываясь на предложении от @tbenson, я попробовал работать с asmlib версией memcpy. Первоначально мои результаты были плохими, но после изменения SetMemcpyCacheLimit() до 1 ГБ (размер моего буфера) я работал со скоростью наравне с моим наивным циклом!

Плохая новость заключается в том, что версия memmove asmlib медленнее, чем версия glibc, теперь она работает на отметке 300 мс (наравне с версией memcpy glibc). Странно то, что на ноутбуке, когда я SetMemcpyCacheLimit() на большое количество, он вредит производительности...

В приведенных ниже строках строки, отмеченные SetCache, имеют SetMemcpyCacheLimit, установленный в 1073741824. Результаты без SetCache не вызывают SetMemcpyCacheLimit()

Результаты с использованием функций из asmlib:

Buffer Size: 1GB  | memcpy (ms) | memmove(ms) | naiveMemcpy()
------------------------------------------------------------
Laptop            | 136         | 132         | 161
Laptop SetCache   | 182         | 137         | 161
Server 1          | 305         | 302         | 164
Server 1 SetCache | 162         | 303         | 164
Server 2          | 300         | 299         | 166
Server 2 SetCache | 166         | 301         | 166

Начинаем опираться на проблему с кешем, но что может вызвать это?

Ответ 1

[Я бы сделал это комментарием, но для этого недостаточно репутации.]

У меня есть аналогичная система и вы видите похожие результаты, но можете добавить несколько точек данных:

  • Если вы отмените направление наивного memcpy (т.е. преобразуете в *p_dest-- = *p_src--), вы можете получить гораздо худшую производительность, чем для прямого направления (~ 637 мс для меня). В glibc 2.12 произошла смена memcpy(), которая выявила несколько ошибок для вызова memcpy на перекрывающиеся буферы (http://lwn.net/Articles/414467/), и я считаю, что проблема была вызвана переход на версию memcpy, которая работает в обратном порядке. Таким образом, обратная или прямая копия может объяснить несоответствие memcpy()/memmove().
  • Кажется, лучше не использовать невременные магазины. Многие оптимизированные реализации memcpy() переключаются на невременные хранилища (которые не кэшируются) для больших буферов (то есть больше, чем кеш последнего уровня). Я протестировал версию memcpy от Agner Fog (http://www.agner.org/optimize/#asmlib) и обнаружил, что она была примерно такой же, как версия в glibc. Однако asmlib имеет функцию (SetMemcpyCacheLimit), которая позволяет установить порог, над которым используются невременные хранилища. Установка этого ограничения на 8GiB (или только больше, чем 1 гигабайтный буфер), чтобы избежать невременного хранения удвоенной производительности в моем случае (время до 176 мс). Конечно, это только соответствует наивысшей производительности в прямом направлении, поэтому оно не звездное.
  • BIOS в этих системах позволяет включать/отключать четыре различных аппаратных префетчера (MFC Streamer Prefetcher, MFC Spatial Prefetcher, Prefetcher для DCU Streamer и Prefetcher для DCU). Я попытался отключить каждый, но при этом в лучшем случае поддерживался паритет производительности и снижение производительности для нескольких параметров.
  • Отключение режима работы с минимальным уровнем мощности (RAPL) DRAM не влияет.
  • У меня есть доступ к другим системам Supermicro с Fedora 19 (glibc 2.17). С платами Supermicro X9DRG-HF, Fedora 19 и Xeon E5-2670 я вижу аналогичную производительность, как указано выше. На одноплатной плате Supermicro X10SLM-F, работающей на Xeon E3-1275 v3 (Haswell) и Fedora 19, я вижу 9,6 ГБ/с для memcpy (104 мс). ОЗУ на системе Haswell - DDR3-1600 (как и другие системы).

ОБНОВЛЕНИЯ

  • Я установил для управления мощностью процессора максимальную производительность и отключил гиперпотоки в BIOS. На основе /proc/cpuinfo, ядра были затем синхронизированы с частотой 3 ГГц. Однако это странно снизило производительность памяти примерно на 10%.
  • memtest86 + 4.10 сообщает о пропускной способности основной памяти 9091 МБ/с. Я не мог найти, соответствует ли это чтению, записи или копированию.
  • тест STREAM сообщает 13422 МБ/с для копирования, но они подсчитывают байты как прочитанные и написанные, так что это соответствует ~ 6.5 ГБ/с, если мы хотим для сравнения с приведенными выше результатами.

Ответ 2

Это выглядит нормально для меня.

Управление 8x16GB ECC-картами памяти с двумя процессорами - гораздо более сложная работа, чем один процессор с 2x2GB. Ваши 16-гигабайтные палочки - двухсторонняя память + у них могут быть буферы + ECC (даже отключенные на уровне материнской платы)... все, что делает путь данных к ОЗУ намного дольше. У вас также есть 2 процессора, которые используют ram, и даже если вы ничего не делаете на другом CPU, всегда есть доступ к памяти. Для переключения этих данных требуется некоторое дополнительное время. Просто посмотрите на огромную производительность, проигравшую на ПК, которые используют один барабан с графической картой.

По-прежнему ваши отрывки - действительно мощные datapumps. Я не уверен, что дублирование 1GB происходит очень часто в реальном программном обеспечении, но я уверен, что ваши 128GB намного быстрее, чем любой жесткий диск, даже лучший SSD, и именно здесь вы можете воспользоваться преимуществами своих серверов. Выполнение одного и того же теста с помощью 3 ГБ поднимет ваш ноутбук.

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

Спасибо за ваш очень подробный вопрос.

РЕДАКТИРОВАТЬ: (взял меня так долго, чтобы написать этот ответ, что я пропустил часть графика.)

Я думаю, проблема в том, где хранятся данные. Можете ли вы сравнить это:

  • test one: выделите два смежных блока с 500 Мб оперативной памяти и скопируйте их из одного в другой (что вы уже сделали)
  • test two: выделите 20 (или более) блоков из 500 Мб памяти и скопируйте их с первого до последнего, так что они находятся далеко друг от друга (даже если вы не можете быть уверены в их реальной позиции).

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

Кроме того, вы уверены, что поток связан с одним процессором?

ИЗМЕНИТЬ 2:

Существует несколько типов разделителей "зон" для памяти. NUMA - это одно, но это не единственное. Например, для двухсторонних стержней требуется флаг для адреса одной или другой стороны. Посмотрите на свой график, как производительность ухудшается с большим объемом памяти даже на ноутбуке (у которого нет NUMA). Я не уверен в этом, но memcpy может использовать аппаратную функцию для копирования ram (своего рода DMA), и этот чип должен иметь меньше кеша, чем ваш процессор, это может объяснить, почему немой копия с процессором быстрее, чем memcpy.

Ответ 3

Возможно, некоторые улучшения процессора в вашем ноутбуке на базе IvyBridge способствуют этому усилению над серверами на базе SandyBridge.

  • Предварительная выборка страницы - ваш процессор для ноутбуков предварительно запрограммировал бы следующую линейную страницу всякий раз, когда вы достигнете конца текущего, сохраняя неприятный TLB пропустить каждый раз. Чтобы попытаться смягчить это, попробуйте создать код сервера для страниц 2M/1G.

  • Схемы замены кэша также, похоже, были улучшены (см. интересную обратную разработку здесь). Если на самом деле этот процессор использует динамическую политику вставки, это легко помешает вашим скопированным данным пытаться превзойти ваш последний уровень-кеш (который он не может эффективно использовать в любом случае из-за размера) и сохранить место для другого полезного кэширования например, код, стек, данные таблицы страниц и т.д.). Чтобы проверить это, вы можете попытаться восстановить свою наивную реализацию, используя потоковые нагрузки/хранилища (movntdq или аналогичные, вы также можете использовать для этого gcc builtin). Эта возможность может объяснить внезапное падение больших размеров данных.

  • Я считаю, что некоторые улучшения были сделаны и с копией строки (здесь), она может или не может применяться здесь, в зависимости от того, как ваша сборка код выглядит. Вы можете попробовать сравнительный анализ с Dhrystone, чтобы проверить, есть ли разница. Это также может объяснить разницу между memcpy и memmove.

Если бы вы могли получить доступ к серверу на базе IvyBridge или ноутбуку Sandy-Bridge, было бы проще протестировать все это вместе.

Ответ 4

Я изменил бенчмарк, чтобы использовать таймер nsec в Linux, и нашел похожие варианты на разных процессорах, все с подобной памятью. Все выполняемые RHEL 6. Числа согласованы между несколькими запусками.

Sandy Bridge E5-2648L v2 @ 1.90GHz, HT enabled, L2/L3 256K/20M, 16 GB ECC
malloc for 1073741824 took 47us 
memset for 1073741824 took 643841us
memcpy for 1073741824 took 486591us 

Westmere E5645 @2.40 GHz, HT not enabled, dual 6-core, L2/L3 256K/12M, 12 GB ECC
malloc for 1073741824 took 54us
memset for 1073741824 took 789656us 
memcpy for 1073741824 took 339707us

Jasper Forest C5549 @ 2.53GHz, HT enabled, dual quad-core, L2 256K/8M, 12 GB ECC
malloc for 1073741824 took 126us
memset for 1073741824 took 280107us 
memcpy for 1073741824 took 272370us

Вот результаты с встроенным кодом C -O3

Sandy Bridge E5-2648L v2 @ 1.90GHz, HT enabled, 256K/20M, 16 GB
malloc for 1 GB took 46 us
memset for 1 GB took 478722 us
memcpy for 1 GB took 262547 us

Westmere E5645 @2.40 GHz, HT not enabled, dual 6-core, 256K/12M, 12 GB
malloc for 1 GB took 53 us
memset for 1 GB took 681733 us
memcpy for 1 GB took 258147 us

Jasper Forest C5549 @ 2.53GHz, HT enabled, dual quad-core, 256K/8M, 12 GB
malloc for 1 GB took 67 us
memset for 1 GB took 254544 us
memcpy for 1 GB took 255658 us

Для этого я также попытался сделать встроенную memcpy по 8 байтов за раз. На этих процессорах Intel это не имело заметной разницы. Кэш объединяет все операции байта с минимальным количеством операций с памятью. Я подозреваю, что код библиотеки gcc пытается быть слишком умным.

Ответ 5

Вопрос уже был ответил выше, но в любом случае вот реализация с использованием AVX, которая должна быть быстрее для больших копий, если это вас беспокоит:

#define ALIGN(ptr, align) (((ptr) + (align) - 1) & ~((align) - 1))

void *memcpy_avx(void *dest, const void *src, size_t n)
{
    char * d = static_cast<char*>(dest);
    const char * s = static_cast<const char*>(src);

    /* fall back to memcpy() if misaligned */
    if ((reinterpret_cast<uintptr_t>(d) & 31) != (reinterpret_cast<uintptr_t>(s) & 31))
        return memcpy(d, s, n);

    if (reinterpret_cast<uintptr_t>(d) & 31) {
        uintptr_t header_bytes = 32 - (reinterpret_cast<uintptr_t>(d) & 31);
        assert(header_bytes < 32);

        memcpy(d, s, min(header_bytes, n));

        d = reinterpret_cast<char *>(ALIGN(reinterpret_cast<uintptr_t>(d), 32));
        s = reinterpret_cast<char *>(ALIGN(reinterpret_cast<uintptr_t>(s), 32));
        n -= min(header_bytes, n);
    }

    for (; n >= 64; s += 64, d += 64, n -= 64) {
        __m256i *dest_cacheline = (__m256i *)d;
        __m256i *src_cacheline = (__m256i *)s;

        __m256i temp1 = _mm256_stream_load_si256(src_cacheline + 0);
        __m256i temp2 = _mm256_stream_load_si256(src_cacheline + 1);

        _mm256_stream_si256(dest_cacheline + 0, temp1);
        _mm256_stream_si256(dest_cacheline + 1, temp2);
    }

    if (n > 0)
        memcpy(d, s, n);

    return dest;
}

Ответ 6

Цифры имеют смысл для меня. Здесь есть два вопроса, и я отвечу им обоим.

Во-первых, нам нужно иметь ментальную модель того, как большие передачи памяти 1 работают на чем-то вроде современного процессора Intel. Это описание является приблизительным, и детали могут несколько меняться от архитектуры к архитектуре, но идеи высокого уровня довольно постоянны.

  • Когда в кэше данных L1 отсутствует промашка, выделяется буфер строки, который будет отслеживать запрос на пропуски до его заполнения. Это может быть на короткое время (дюжина циклов или около того), если он попадает в кеш L2 или намного дольше (100+ наносекунд), если он пропустит весь путь до DRAM.
  • Существует ограниченное количество этих линейных буферов на ядро ​​ 1 и как только они будут заполнены, дальнейшие промахи будут ждать ожидания.
  • Помимо этих буферов заполнения, используемых для запросов 3 загрузок/хранилищ, существуют дополнительные буферы для перемещения памяти между DRAM и L2 и кэшами нижнего уровня, используемыми для предварительной выборки.
  • Сама подсистема памяти имеет максимальный предел пропускной способности, который удобно найти в ARK. Например, 3720QM в ноутбуке Lenovo показывает предел 25.6 GB. Этот предел в основном является продуктом эффективной частоты (1600 Mhz) раз 8 байтов (64 бит) за время передачи количества каналов (2): 1600 * 8 * 2 = 25.6 GB/s. Шина сервера на руке имеет максимальную пропускную способность 51,2 ГБ/с для каждого сокета для общей пропускной способности системы ~ 102 ГБ/с.

    В отличие от других функций процессора часто встречаются только теоретические номера полосы пропускания для всего множества чипов, поскольку это зависит только от отмеченных значений, которые часто одинаковы для многих разные чипы и даже по архитектурам. Это нереально ожидайте, что DRAM достигнет точно теоретической скорости (из-за различных проблемы низкого уровня, обсуждались немного здесь), но вы часто можете получить около 90% и более.

Таким образом, основным следствием (1) является то, что вы можете рассматривать промахи в ОЗУ как своего рода систему ответа на запрос. Промаха для DRAM выделяет буфер заполнения, и буфер возвращается, когда запрос возвращается. Всего 10 таких буферов для каждого процессора для пропусков спроса, что устанавливает ограничение строгое ограничение на пропускную способность памяти потребностей, которую может генерировать один процессор, в зависимости от его задержки.

Например, скажем, ваш E5-2680 имеет задержку до DRAM 80ns. Каждый запрос вводит строку с байтом в 64 байта, поэтому вы просто отправляете запросы последовательно DRAM, ожидая пропускную способность ничтожного 64 bytes / 80 ns = 0.8 GB/s, и вы бы сократили это пополам (по крайней мере), чтобы получить memcpy потому что ему нужно читать и писать. К счастью, вы можете использовать 10 буферов с заполнением строки, чтобы вы могли перекрывать 10 параллельных запросов в память и увеличивать пропускную способность в 10 раз, что приводит к теоретической пропускной способности 8 ГБ/с.

Если вы хотите вникнуть в более подробные сведения, этот поток - это очень чистое золото. Вы увидите, что факты и цифры из Джон МакКалпин, также известный как" Dr Bandwidth, будет общей темой ниже.

Итак, давайте вдаваться в детали и ответить на два вопроса...

Почему memcpy настолько медленнее, чем memmove или ручная копия на сервере?

Вы показали, что ваши ноутбуковые системы выполняют тег memcpy примерно в 120 мс, а части сервера занимают 300 мс. Вы также показали, что эта медлительность в основном не является фундаментальной, так как вы могли использовать memmove и вашу ручную-memcpy (далее hrm) для достижения времени около 160 мс, много ближе (но все же медленнее) производительность ноутбука.

Мы уже показали выше, что для одного ядра ширина полосы ограничена общей доступной concurrency и задержкой, а не шириной полосы DRAM. Мы ожидаем, что серверные части могут иметь более длительную задержку, но не 300 / 120 = 2.5x дольше!

Ответ лежит в потоковой (ака невременной) записи. Используемая вами версия libc memcpy использует их, но memmove нет. Вы так же подтвердили свой "наивный" memcpy, который также не использует их, а также мою настройку asmlib для использования потоковых хранилищ (медленных) и не (быстрых).

Потоковые хранилища повреждают одиночные номера ЦП, потому что:

  • (А)Они предотвращают предварительную выборку из-за ввода в кеш-память строк, что позволяет больше concurrency, поскольку оборудование предварительной выборки имеет другие выделенные буферы за пределами 10 буферов заполнения, которые требуют загрузки/хранения.
  • (B) E5-2680, как известно, особенно медленный для потоковых хранилищ.

Оба вопроса лучше объясняются цитатами из Джона МакКальпина в вышеупомянутой связанной теме. На тему эффективности предвыборки и потоковых хранилищ он говорит:

В "обычных" магазинах аппаратный предварительный выбор L2 может извлекать строки в продвигать и сокращать время заполнения буферов заполнения строк, тем самым увеличивая пропускную способность. С другой стороны, с потоки (кэширование), записи буфера линии заливки для магазины заняты в течение полного времени, необходимого для передачи данных в контроллер DRAM. В этом случае нагрузки могут быть ускорены аппаратная предварительная выборка, но магазины не могут, поэтому вы получаете некоторое ускорение, но не столько, сколько вы могли бы получить, если бы были загружены как магазины, так и магазины ускоряется.

... а затем для явно большей задержки для потоковых хранилищ на E5, он говорит:

Более простое "несохранение" Xeon E3 может привести к значительному снижению Заполнение буфера заполнения строки для потоковых хранилищ. Xeon E5 имеет гораздо более сложную кольцевую структуру для навигации, чтобы отменить потоковые хранилища от базовых буферов к контроллерам памяти, поэтому занятость может отличаться на больший коэффициент, чем память (чтение) латентность.

В частности, д-р Маккальпин измерил замедление в 1,8 раза для E5 по сравнению с чипом с "клиентом", но 2,5-процентное замедление отчетов OP согласуется с тем, что с оценкой 1.8x для STREAM TRIAD, который имеет отношение нагрузок 2: 1, а memcpy составляет 1:1, а хранилища являются проблематичной частью.

Это не делает потоковой передачи плохой - по сути, вы торгуете с задержкой для меньшего общего потребления полосы пропускания. Вы получаете меньшую пропускную способность, потому что вы ограничены concurrency при использовании одного ядра, но вы избегаете весь трафик чтения для владения, поэтому вы, скорее всего, увидите (небольшое) преимущество, если бы вы запускали тест одновременно на всех ядрах.

До сих пор не являясь артефактом конфигурации вашего программного обеспечения или аппаратного обеспечения, о том же замедлении сообщали другие пользователи с тем же процессором.

Почему серверная часть еще медленнее при использовании обычных магазинов?

Даже после исправления неповторяющейся проблемы с хранилищем вы по-прежнему наблюдаете примерно за 160 / 120 = ~1.33x замедление на частях сервера. Что дает?

Ну, это распространенная ошибка, что серверные процессоры быстрее во всех отношениях быстрее или, по крайней мере, равны их клиентским коллегам. Это просто неправда - то, за что вы платите (часто на чипе или около 2000 долларов) на серверных частях, в основном (a) больше ядер (b) больше каналов памяти (c) поддержка более полной ОЗУ (d) поддержка "enterprise-ish", такие как функции ECC, virutalization и т.д. 5.

Фактически, задержек, серверные части обычно равны или медленнее для своих клиентских частей 4. Когда дело доходит до задержки памяти, это особенно верно, потому что:

  • Части сервера имеют более масштабируемое, но сложное "uncore", которое часто требует поддержки многих других ядер, и, следовательно, путь к ОЗУ более длинный.
  • Серверные части поддерживают больше оперативной памяти (100 ГБ или несколько ТБ), для которых часто требуется электрические буферы для поддержки такого большого количества.
  • Как и в случае OP, серверные части обычно являются многосетевыми, что добавляет проблемы согласованности кросс-сокетов в путь памяти.

Итак, типично, что части сервера имеют задержку на 40-60% дольше, чем клиентские части. Для E5 вы, вероятно, обнаружите, что ~ 80 нс - это типичная латентность для ОЗУ, а части клиента ближе к 50 нс.

Таким образом, все, что ограничено задержкой в ​​RAM, будет работать медленнее на частях сервера, и, как оказалось, memcpy на одном ядре ограничено с задержкой. что запутанно, потому что memcpy похоже на измерение пропускной способности, не так ли? Как описано выше, одно ядро ​​не имеет достаточных ресурсов для одновременного хранения достаточного количества запросов на RAM в полете, чтобы приблизиться к полосе пропускания ОЗУ 6 поэтому производительность напрямую зависит от латентности.

С другой стороны, клиентские чипы имеют как более низкую задержку, так и низкую пропускную способность, поэтому одно ядро ​​значительно приближается к насыщению полосы пропускания (часто это связано с тем, что потоковые хранилища являются большой победой на клиентских частях - когда даже одно ядро могут приблизиться к полосе пропускания ОЗУ, 50% -ное ограничение пропускной способности магазина, которое предлагает потоковые хранилища, помогает много.

Ссылки

Есть много хороших источников, чтобы больше узнать об этом, вот пара.


1 По большому счету я имею в виду несколько больше, чем LLC. Для копий, которые вписываются в LLC (или любой более высокий уровень кеша), поведение очень отличается. График OPs llcachebench показывает, что фактически отклонение производительности начинается только тогда, когда буферы начинают превышать размер LLC.

2 В частности, количество буферов заполнения строк по-видимому, было постоянным в 10 в течение нескольких поколений, включая архитектуры, упомянутые в этом вопросе.

3 Когда мы говорим о требовании здесь, мы имеем в виду, что оно связано с явной загрузкой/хранением в коде, а не с помощью предварительной выборки.

4 Когда я ссылаюсь на часть сервера здесь, я имею в виду процессор с сервером. Это в значительной степени означает серию E5, так как серия E3 обычно использует клиентский знак.

5 В будущем, похоже, вы можете добавить "расширения набора инструкций" в этот список, так как кажется, что AVX-512 появится только на частях сервера Skylake.

6 Per немного права за латентность 80 нс, нам понадобится (51.2 B/ns * 80 ns) == 4096 bytes или 64 кэш-линии в полете в любое время, чтобы достичь максимальной пропускной способности, но одно ядро ​​обеспечивает менее 20.

Ответ 7

Характеристики сервера 1

  • Процессор: 2x Intel Xeon E5-2680 @2.70 Ghz

Характеристики сервера 2

  • Процессор: 2x Intel Xeon E5-2650 v2 @2.6 Ghz

Согласно Intel ARK, E5-2650 и E5-2680 имеют расширение AVX.

Файл CMake для сборки

Это часть вашей проблемы. CMake выбирает для вас несколько довольно бедных флагов. Вы можете подтвердить его, выполнив make VERBOSE=1.

Вы должны добавить как -march=native, так и -O3 к вашим CFLAGS и CXXFLAGS. Вероятно, вы увидите резкое увеличение производительности. Он должен задействовать расширения AVX. Без -march=XXX вы получите минимальную машину i686 или x86_64. Без -O3 вы не включаете векторизацию GCC.

Я не уверен, что GCC 4.6 способен на AVX (и друзей, таких как BMI). Я знаю, что GCC 4.8 или 4.9 способна, потому что мне пришлось выслеживать ошибку выравнивания, которая вызывала segfault, когда GCC выполнял аутсорсинг memcpy и memset для MMX. AVX и AVX2 позволяют процессору работать с 16-байтовыми и 32-байтовыми блоками данных за раз.

Если GCC не имеет возможности отправлять согласованные данные в блок MMX, может отсутствовать тот факт, что данные выровнены. Если ваши данные выровнены по 16 байт, вы можете попытаться рассказать GCC, чтобы он знал, что нужно работать с жировыми блоками. Для этого см. GCC __builtin_assume_aligned. Также см. Такие вопросы, как Как сообщить GCC, что аргумент указателя всегда совмещен с двойным словом?

Это также выглядит немного подозрительным из-за void*. Его вид отбрасывания информации об указателе. Вероятно, вы должны сохранить информацию:

void doMemmove(void* pDest, const void* pSource, std::size_t sizeBytes)
{
  memmove(pDest, pSource, sizeBytes);
}

Возможно, что-то вроде следующего:

template <typename T>
void doMemmove(T* pDest, const T* pSource, std::size_t count)
{
  memmove(pDest, pSource, count*sizeof(T));
}

Другое предложение - использовать new и прекратить использование malloc. Его С++-программа и GCC могут делать некоторые предположения о new, которые она не может сделать о malloc. Я считаю, что некоторые из допущений подробно описаны на странице параметров GCC для встроенных модулей.

Еще одно предложение - использовать кучу. Его всегда 16-байт выровнен по типичным современным системам. GCC должен признать, что он может разгружаться в MMX-модуль, когда задействован указатель из кучи (без возможностей void* и malloc).

Наконец, некоторое время Clang не использовал собственные расширения процессора при использовании -march=native. См., Например, Ubuntu Issue 1616723, Clang 3.4 рекламирует только SSE2, Ubuntu Issue 1616723, Clang 3.5 рекламирует только SSE2 и Ubuntu Issue 1616723, Clang 3.6 рекламирует только SSE2.