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

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

Спецификации сервера

  • Ходовая часть и Мобо: SUPER MICRO 1027GR-TRF
  • Процессор: 2x Intel Xeon E5-2680 при 2,70 ГГц
  • Память: 8x 16 ГБ DDR3 1600 МГц

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

Server 2 Specs

  • Ходовая часть и Мобо: SUPER MICRO 10227GR-TRFT
  • Процессор: 2x Intel Xeon E5-2650 v2 @ 2,6 ГГц
  • Память: 8x 16 ГБ DDR3 1866 МГц

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

  • Шасси: Lenovo W530
  • Процессор: 1x Intel Core i7 i7-3720QM @ 2,6 ГГц
  • Память: 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, поэтому я попытался установить привязку к процессору и памяти с помощью 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 Affinity

$ 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)

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

производительность ноутбука и сервера memcpy

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

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

Основываясь на некоторых экспериментах, я попытался использовать memmove () вместо memcpy () в моем тестовом примере и обнаружил двукратное улучшение на сервере. 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

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

ПРОГРЕСС!!!! ASMlib

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

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

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

69

Решение

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

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

  • Если вы измените направление своей наивности memcpy (т.е. преобразовать в *p_dest-- = *p_src--), тогда вы можете получить гораздо худшую производительность, чем для прямого направления (для меня ~ 637 мс). Произошло изменение в memcpy() в glibc 2.12, который выявил несколько ошибок для вызова memcpy на перекрывающихся буферах (http://lwn.net/Articles/414467/) и я считаю, что проблема была вызвана переходом на версию memcpy это работает в обратном направлении. Таким образом, обратная или прямая копии могут объяснить memcpy()/memmove() несоответствие.
  • Кажется, лучше не использовать временные магазины. Многие оптимизированы memcpy() реализации переключаются на временные хранилища (которые не кэшируются) для больших буферов (то есть больше, чем кэш последнего уровня). Я проверил версию Memcpy Агнера Фога (http://www.agner.org/optimize/#asmlib) и обнаружил, что скорость была примерно такой же, как у версии в glibc, Тем не мение, asmlib имеет функцию (SetMemcpyCacheLimit), что позволяет установить порог, выше которого используются не временные хранилища. Установка этого предела на 8 ГБ (или просто больше, чем буфер 1 ГБ), чтобы избежать невременных накопителей, удвоила производительность в моем случае (время до 176 мс). Конечно, это только соответствовало наивному исполнению в прямом направлении, поэтому оно не звездное.
  • BIOS в этих системах позволяет включать / отключать четыре разных аппаратных средства предварительной выборки (MLC Streamer Prefetcher, MLC Spatial Prefetcher, DCU Streamer Prefetcher и DCU IP Prefetcher). Я пытался отключить каждый из них, но в лучшем случае поддерживал паритет производительности и снижал производительность для некоторых параметров.
  • Отключение режима оперативной памяти (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 (104ms). Оперативная память в системе Haswell составляет DDR3-1600 (так же, как и в других системах).

ОБНОВЛЕНИЕ

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

Другие решения

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

Управление картами памяти ECC объемом 8×16 ГБ с двумя ЦП является гораздо более сложной задачей, чем использование одного ЦП с 2×2 ГБ. Ваши 16 ГБ флешки — это двухсторонняя память + они могут иметь буферы + ECC (даже отключенные на уровне материнской платы) … все, что делает путь данных к ОЗУ намного длиннее У вас также есть 2 ЦП, разделяющих оперативную память, и даже если вы ничего не делаете на другом ЦП, доступ к памяти всегда ограничен. Переключение этих данных требует дополнительного времени. Достаточно взглянуть на огромную производительность, потерянную на компьютерах, которые разделяют видеокарту с графической картой.

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

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

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

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

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

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

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

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

РЕДАКТИРОВАТЬ 2:

Есть несколько видов «зон» разделителя для памяти. НУМА одна, но не единственная. Например, для двухсторонних палочек требуется флаг для адресации одной или другой стороны. Посмотрите на график, как производительность падает при большом объеме памяти даже на ноутбуке (у которого нет NUMA).
Я не уверен в этом, но memcpy может использовать аппаратную функцию для копирования оперативной памяти (разновидность DMA), и этот чип должен иметь меньше кеша, чем ваш ЦП, это может объяснить, почему тупое копирование с ЦП выполняется быстрее, чем memcpy.

10

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

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

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

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

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

8

Я изменил тест для использования таймера 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 пытается быть слишком умным.

4

На вопрос уже был дан ответ выше, но в любом случае, вот реализация, использующая 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;
}
3

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

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

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

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

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

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

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

Итак, давайте углубимся в детали и ответим на два вопроса …

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

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

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

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

Потоковые магазины больно один процессор числа, потому что:

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

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

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

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

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

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

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

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

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

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

Ну, это распространенная ошибка, что процессоры сервера быстрее во всех отношениях быстрее или, по крайней мере, равны своим клиентским аналогам. Это просто неправда — то, что вы платите (часто по 2000 долларов за чип или около того) на серверных частях, это в основном (а) больше ядер (б) больше каналов памяти (в) поддержка большей общей оперативной памяти (d) » такие функции, как ECC, функции виртуализации и т. д.5.

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

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

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

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

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

Рекомендации

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


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

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

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

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

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

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

3

Сервер 1 Спецификации

  • Процессор: 2x Intel Xeon E5-2680 при 2,70 ГГц

Server 2 Specs

  • Процессор: 2x Intel Xeon E5-2650 v2 @ 2,6 ГГц

Согласно 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 способен, потому что мне пришлось выискивать ошибку выравнивания, которая вызывала сегфоут, когда 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, Это программа C ++, и 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.

0
По вопросам рекламы [email protected]