Плохая производительность 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).
Я построил тест для каждой машины отдельно, чтобы избежать проблем с архитектурой. Ниже приведены мои результаты.
Обратите внимание, что ОЧЕНЬ большое различие заключается в производительности на больших размерах буфера. Последний протестированный размер (16777216) был выполнен на скорости 18849,29 МБ / с на ноутбуке и 6710,40 на сервере. Это примерно в 3 раза разница в производительности. Вы также можете заметить, что снижение производительности сервера намного круче, чем на ноутбуке.
Изменить: memmove() в 2 раза быстрее, чем memcpy() на сервере
Основываясь на некоторых экспериментах, я попытался использовать memmove () вместо memcpy() в моем тестовом примере и нашел 2-кратное улучшение на сервере. 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
Начинаю склоняться к проблеме с кешем, но что может вызвать это?
7 ответов
[Я хотел бы сделать это комментарий, но у меня недостаточно репутации, чтобы сделать это.]
У меня похожая система, и я вижу похожие результаты, но могу добавить несколько точек данных:
- Если вы измените направление своего наивного
memcpy
(т.е. преобразовать в*p_dest-- = *p_src--
), тогда вы можете получить гораздо худшую производительность, чем для прямого направления (для меня ~637 мс). Произошло изменение вmemcpy()
в glibc 2.12, который выявил несколько ошибок для вызоваmemcpy
на перекрывающихся буферах ( http://lwn.net/Articles/414467/), и я считаю, что проблема была вызвана переключением на версиюmemcpy
это работает в обратном направлении. Таким образом, обратная или прямая копии могут объяснитьmemcpy()
/memmove()
несоответствие. - Кажется, лучше не использовать временные магазины. Многие оптимизированы
memcpy()
Реализации переключаются на временные хранилища (которые не кэшируются) для больших буферов (то есть больше, чем кэш последнего уровня). Я проверил версию Memcpy от Agner Fog ( http://www.agner.org/optimize/) и обнаружил, что скорость примерно такая же, как у версии вglibc
, Тем не мение,asmlib
имеет функцию (SetMemcpyCacheLimit
), что позволяет установить порог, выше которого используются временные хранилища. Установка этого предела на 8 ГБ (или просто больше, чем буфер 1 ГБ), чтобы избежать невременных накопителей, удвоила производительность в моем случае (время до 176 мс). Конечно, это только соответствовало наивному исполнению в прямом направлении, поэтому оно не звездное. - BIOS в этих системах позволяет включать / отключать четыре разных аппаратных средства предварительной выборки (MLC Streamer Prefetcher, MLC Spatial Prefetcher, DCU Streamer Prefetcher и DCU IP Prefetcher). Я пытался отключить каждый из них, но в лучшем случае поддерживал паритет производительности и снижал производительность для некоторых параметров.
- Отключение режима DRAM текущего ограничения средней мощности (RAPL) не оказывает влияния.
- У меня есть доступ к другим системам 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 ГБ / с, если мы хотим сравнить с приведенными выше результатами.
Это выглядит нормально для меня.
Управление 8x16GB ECC картами памяти с двумя процессорами - гораздо более сложная задача, чем один процессор с 2x2GB. Ваши 16 ГБ флешки - это двухсторонняя память + они могут иметь буферы + ECC (даже отключенные на уровне материнской платы)... все, что делает путь данных к ОЗУ намного длиннее. У вас также есть 2 ЦП, разделяющих оперативную память, и даже если вы ничего не делаете на другом ЦП, доступ к памяти всегда ограничен. Переключение этих данных требует дополнительного времени. Достаточно взглянуть на огромную производительность, потерянную на ПК, которые разделяют некоторый ОЗУ с графической картой.
Тем не менее ваши серверы действительно мощные насосы данных. Я не уверен, что дублирование 1 ГБ происходит очень часто в реальном программном обеспечении, но я уверен, что ваши 128 ГБ намного быстрее, чем любой жесткий диск, даже лучший SSD, и именно здесь вы можете использовать преимущества своих серверов. Выполнение того же теста с 3 ГБ подожжет ваш ноутбук.
Это выглядит как прекрасный пример того, как архитектура, основанная на стандартном оборудовании, может быть гораздо более эффективной, чем большие серверы. Сколько потребительских ПК можно было бы позволить себе, потратив деньги на эти большие серверы?
Спасибо за ваш очень подробный вопрос.
РЕДАКТИРОВАТЬ: (я так долго писал этот ответ, что я пропустил часть графика.)
Я думаю, что проблема в том, где хранятся данные. Можете ли вы сравнить это:
- первый тест: выделите два смежных блока оперативной памяти объемом 500 МБ и скопируйте их с одного на другой (что вы уже сделали)
- второй тест: выделите 20 (или более) блоков памяти объемом 500 МБ и скопируйте данные с первого на последний, чтобы они находились далеко друг от друга (даже если вы не можете быть уверены в их реальном положении).
Таким образом, вы увидите, как контроллер памяти обрабатывает блоки памяти далеко друг от друга. Я думаю, что ваши данные помещаются в разные зоны памяти, и в какой-то момент пути передачи данных требуется операция переключения, чтобы обмениваться данными с одной зоной, а затем с другой (существует такая проблема с двухсторонней памятью).
Кроме того, вы гарантируете, что поток связан с одним процессором?
РЕДАКТИРОВАТЬ 2:
Есть несколько видов "зон" разделителя для памяти. NUMA одна, но не единственная. Например, для двухсторонних палочек требуется флаг для адресации одной или другой стороны. Посмотрите на график, как производительность падает при большом объеме памяти даже на ноутбуке (у которого нет NUMA). Я не уверен в этом, но memcpy может использовать аппаратную функцию для копирования оперативной памяти (разновидность DMA), и этот чип должен иметь меньше кеша, чем ваш ЦП, это может объяснить, почему тупое копирование с ЦП выполняется быстрее, чем memcpy.
Вполне возможно, что некоторые улучшения процессора вашего ноутбука на базе IvyBridge способствуют этому выигрышу по сравнению с серверами на базе SandyBridge.
Предварительная выборка при пересечении страниц - процессор вашего ноутбука будет выполнять предварительную выборку перед следующей линейной страницей, когда вы достигнете конца текущей страницы, что каждый раз избавляет вас от неприятного промаха по TLB. Чтобы попытаться смягчить это, попробуйте создать свой код сервера для страниц 2M / 1G.
Схемы замены кэша также, похоже, были улучшены (см. Интересный реверс-инжиниринг здесь). Если действительно этот ЦП использует динамическую политику вставки, он легко предотвратит попытки скопировать скопированные данные в кэш-память последнего уровня (которую он в любом случае не может эффективно использовать из-за размера), и оставит место для другого полезного кэширования. например, код, стек, данные таблицы страниц и т. д.). Чтобы проверить это, вы можете попробовать перестроить свою наивную реализацию, используя потоковые загрузки / хранилища (
movntdq
или аналогичные, вы также можете использовать встроенный gcc для этого). Эта возможность может объяснить внезапное падение больших размеров набора данных.Я полагаю, что некоторые улучшения были также сделаны с копированием строки ( здесь), это может или не может применяться здесь, в зависимости от того, как выглядит ваш ассемблерный код. Вы можете попробовать сравнительный анализ с Dhrystone, чтобы проверить, есть ли существенная разница. Это также может объяснить разницу между memcpy и memmove.
Если бы вы могли приобрести сервер на базе IvyBridge или ноутбук Sandy-Bridge, было бы проще всего протестировать все это вместе.
Числа имеют смысл для меня. Здесь на самом деле два вопроса, и я отвечу на них оба.
Во-первых, нам нужно иметь мысленную модель того, как большие объемы1 памяти работают на чем-то вроде современного процессора Intel. Это описание является приблизительным, и детали могут несколько изменяться от архитектуры к архитектуре, но идеи высокого уровня довольно постоянны.
- Когда нагрузка отсутствует в
L1
В кеше данных выделяется линейный буфер, который будет отслеживать запрос на пропуск до его заполнения. Это может быть в течение короткого времени (дюжина циклов или около того), если оно попадает вL2
кеш, или намного дольше (100+ наносекунд), если он пропускает весь путь к DRAM. - Существует ограниченное количество этих линейных буферов на ядро1, и как только они заполнятся, дальнейшие промахи остановят ожидание одного.
- Помимо этих буферов заполнения, используемых для загрузки / сохранения по требованию3, существуют дополнительные буферы для перемещения памяти между DRAM и L2 и кэши более низкого уровня, используемые при предварительной выборке.
Сама подсистема памяти имеет максимальный предел пропускной способности, который вы легко найдете в списке 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
как использовать потоковые хранилища (медленно), так и нет (быстро).
Потоковые хранилища повреждают отдельные номераЦП, потому что:
- (A) Они препятствуют тому, чтобы предварительная выборка вводила строки, которые должны быть сохранены, в кэш, что обеспечивает больший параллелизм, поскольку аппаратное обеспечение предварительной выборки имеет другие выделенные буферы помимо 10заполняющих буферов, которые требуют использования загрузки / хранения.
- (B) E5-2680, как известно, особенно медленный для потоковых магазинов.
Обе проблемы лучше объясняются цитатами из Джона Маккальпина в приведенной выше ветке. На тему эффективности предварительной загрузки и потоковых магазинов он говорит:
В "обычных" хранилищах аппаратный предварительный выборщик L2 может заранее извлекать строки и сокращать время, в течение которого буферы заполнения линий заняты, тем самым увеличивая устойчивую пропускную способность. С другой стороны, при использовании потоковых хранилищ (обхода кэша) записи буфера заполнения строки для хранилищ заняты в течение полного времени, необходимого для передачи данных в контроллер DRAM. В этом случаезагрузка может быть ускорена с помощью аппаратной предварительной выборки, а хранилища - нет, поэтому вы получаете некоторое ускорение, но не так много, как если бы были ускорены как загрузка, так и хранилища.
... а затем, по-видимому, за гораздо более длительное время ожидания потоковых магазинов на E5, он говорит:
Более простой "uncore" Xeon E3 может привести к значительному снижению загрузки Line Fill Buffer для потоковых магазинов. 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% сокращение пропускной способности хранилища, которое предлагает потоковое хранилище, очень помогает
Рекомендации
Есть много хороших источников, чтобы прочитать больше об этом материале, вот пара.
- Подробное описание компонентов задержки памяти
- Множество задержек памяти приводит к новым и старым процессорам (см.
MemLatX86
а такжеNewMemLat
) ссылки - Детальный анализ задержек памяти в Sandy Bridge (и Opteron)- практически тот же чип, который использует OP.
+1 По большому счету я просто имею ввиду несколько больше, чем LLC. Для копий, которые соответствуют LLC (или любому более высокому уровню кэша), поведение очень отличается. ОПllcachebench
график показывает, что на самом деле отклонение производительности начинается только тогда, когда буферы начинают превышать размер LLC.
2 В частности, число буферов заполнения строк, по- видимому, было постоянным на уровне 10 в течение нескольких поколений, включая архитектуры, упомянутые в этом вопросе.
3 Когда мы говорим "спросить", мы подразумеваем, что это связано с явной загрузкой / хранением в коде, а не с предварительным извлечением.
4 Когда я ссылаюсь здесь на серверную часть, я имею в виду процессор с неядерным сервером. Это в значительной степени означает серию E5, так как в серии E3 обычно используется клиент uncore.
5 В будущем, похоже, вы сможете добавить "расширения набора команд" в этот список, так как кажется, что AVX-512
появится только на серверных частях Skylake.
6 По закону Литтла с задержкой 80 нс нам понадобится (51.2 B/ns * 80 ns) == 4096 bytes
или 64 строки кэша в полете всегда для достижения максимальной пропускной способности, но одно ядро обеспечивает менее 20.
Я изменил тест для использования таймера 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 пытается быть слишком умным.
На этот вопрос уже был дан ответ выше, но в любом случае, здесь есть реализация, использующая 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;
}
Сервер 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.