c++ - que - Pobre memcpy Performance en Linux
memcpy c++ para que sirve (7)
Recientemente hemos comprado algunos servidores nuevos y estamos experimentando un rendimiento de memcpy deficiente. El rendimiento de memcpy es 3 veces más lento en los servidores en comparación con nuestras computadoras portátiles.
Especificaciones del servidor
- Chasis y Mobo: SUPER MICRO 1027GR-TRF
- CPU: 2x Intel Xeon E5-2680 a 2.70 Ghz
- Memoria: 8x 16GB DDR3 1600MHz
Editar: también estoy probando en otro servidor con especificaciones ligeramente más altas y viendo los mismos resultados que el servidor anterior
Especificaciones del servidor 2
- Chasis y Mobo: SUPER MICRO 10227GR-TRFT
- CPU: 2x Intel Xeon E5-2650 v2 a 2.6 Ghz
- Memoria: 8x 16GB DDR3 1866MHz
Especificaciones de computadora portátil
- Chasis: Lenovo W530
- CPU: 1x Intel Core i7 i7-3720QM a 2.6Ghz
- Memoria: 4x 4GB DDR3 1600MHz
Sistema operativo
$ 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
Compilador (en todos los sistemas)
$ gcc --version
gcc (GCC) 4.6.1
También probado con gcc 4.8.2 basado en una sugerencia de @stefan. No hubo diferencia de rendimiento entre los compiladores.
Código de prueba El siguiente código de prueba es una prueba enlatada para duplicar el problema que estoy viendo en nuestro código de producción. Sé que este punto de referencia es simplista, pero fue capaz de explotar e identificar nuestro problema. El código crea dos búferes de 1 GB y memcpys entre ellos, cronometrando la llamada memcpy. Puede especificar tamaños de búfer alternativos en la línea de comando usando: ./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 Archivo para construir
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})
Resultados de la prueba
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
Como puede ver, los memcpys y los memsets en nuestros servidores son mucho más lentos que los memcpys y los memsets en nuestros portátiles.
Tamaños de buffer variables
He probado buffers de 100MB a 5GB, todos con resultados similares (servidores más lentos que la laptop)
Afinidad NUMA
Leí sobre personas que tienen problemas de rendimiento con NUMA, así que intenté establecer la afinidad de la CPU y la memoria con numactl, pero los resultados siguieron siendo los mismos.
Hardware de servidor 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
Laptop NUMA Hardware
$ 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
Configuración de afinidad NUMA
$ numactl --cpunodebind=0 --membind=0 ./big_memcpy_test
Cualquier ayuda para resolver esto es muy apreciada.
Editar: Opciones de GCC
En base a los comentarios que he intentado compilar con diferentes opciones de GCC:
Compilar con -march y -mtune establecido en native
g++ -std=c++0x -Wall -march=native -mtune=native -O3 -DNDEBUG -o big_memcpy_test main.cpp
Resultado: el mismo rendimiento exacto (sin mejoría)
Compilando con -O2 en lugar de -O3
g++ -std=c++0x -Wall -march=native -mtune=native -O2 -DNDEBUG -o big_memcpy_test main.cpp
Resultado: el mismo rendimiento exacto (sin mejoría)
Editar: memset cambiado para escribir 0xF en lugar de 0 para evitar la página NULL (@SteveCox)
No mejora cuando memsetting con un valor distinto de 0 (utilizado 0xF en este caso).
Editar: resultados de Cachebench
Para descartar que mi programa de prueba sea demasiado simplista, descargué un programa real de evaluación comparativa LLCacheBench ( http://icl.cs.utk.edu/projects/llcbench/cachebench.html )
Construí el punto de referencia en cada máquina por separado para evitar problemas de arquitectura. A continuación están mis resultados.
Observe que la diferencia MUY grande es el rendimiento en los tamaños de búfer más grandes. El último tamaño probado (16777216) se realizó a 18849.29 MB / seg en la computadora portátil y 6710.40 en el servidor. Eso es una diferencia de 3 veces en el rendimiento. También puede observar que la caída de rendimiento del servidor es mucho más pronunciada que en la computadora portátil.
Editar: memmove () es 2 veces MÁS RÁPIDO que memcpy () en el servidor
Basado en algunos experimentos, he intentado usar memmove () en lugar de memcpy () en mi caso de prueba y he encontrado una mejora de 2x en el servidor. Memmove () en la computadora portátil funciona más lento que memcpy () pero curiosamente funciona a la misma velocidad que memmove () en el servidor. Esto plantea la pregunta: ¿por qué memcpy es tan lento?
Código actualizado para probar memmove junto con memcpy. Tuve que ajustar el memmove () dentro de una función porque si lo dejaba en línea, GCC lo optimizaba y realizaba exactamente lo mismo que memcpy () (supongo que gcc lo optimizó para memcpy porque sabía que las ubicaciones no se superponían).
Resultados actualizados
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
Edición: Naive Memcpy
Basado en la sugerencia de @Salgar, he implementado mi propia función naive memcpy y la he probado.
Naive 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++;
}
}
Resultados de Naive Memcpy en comparación con memcpy ()
Buffer Size: 1GB | memcpy (ms) | memmove(ms) | naiveMemcpy()
------------------------------------------------------------
Laptop 1 | 113 | 161 | 160
Server 1 | 301 | 159 | 159
Server 2 | 325 | 159 | 159
Editar: Salida de conjunto
Fuente memcpy simple
#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;
}
Salida de ensamblaje: esta es exactamente la misma en el servidor y la computadora portátil. Estoy ahorrando espacio y no pegando ambos.
.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
¡¡¡¡PROGRESO!!!! asmlib
Basado en la sugerencia de @tbenson intenté ejecutar con la versión asmlib de memcpy. Mis resultados inicialmente fueron pobres, pero después de cambiar SetMemcpyCacheLimit () a 1GB (tamaño de mi buffer) estaba corriendo a la par que mi ingenuo ciclo.
La mala noticia es que la versión asmlib de memmove es más lenta que la versión glibc, ahora se ejecuta en la marca de 300ms (a la par con la versión glibc de memcpy). Lo extraño es que en la computadora portátil cuando SetMemcpyCacheLimit () a un número grande daña el rendimiento ...
En los resultados a continuación, las líneas marcadas con SetCache tienen SetMemcpyCacheLimit establecido en 1073741824. Los resultados sin SetCache no llaman a SetMemcpyCacheLimit ()
Resultados usando funciones de 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
Comenzando a inclinarse hacia el problema de caché, pero ¿qué podría causar esto?
Server 1 Specs
- CPU: 2x Intel Xeon E5-2680 @ 2.70 Ghz
Server 2 Specs
- CPU: 2x Intel Xeon E5-2650 v2 @ 2.6 Ghz
According to Intel ARK, both the E5-2650 and E5-2680 have AVX extension.
CMake File to Build
This is part of your problem. CMake chooses some rather poor flags for you. You can confirm it by running make VERBOSE=1
.
You should add both -march=native
and -O3
to your CFLAGS
and CXXFLAGS
. You will likely see a dramatic performance increase. It should engage the AVX extensions. Without -march=XXX
, you effectively get a minimal i686 or x86_64 machine. Without -O3
, you don''t engage GCC''s vectorizations.
I''m not sure if GCC 4.6 is capable of AVX (and friends, like BMI). I know GCC 4.8 or 4.9 is capable because I had to hunt down an alignment bug that was causing a segfault when GCC was outsourcing memcpy''s and memset''s to the MMX unit. AVX and AVX2 allow the CPU to operate on 16-byte and 32-byte blocks of data at a time.
If GCC is missing an opportunity to send aligned data to the MMX unit, it may be missing the fact that data is aligned. If your data is 16-byte aligned, then you might try telling GCC so it knows to operate on fat blocks. For that, see GCC''s __builtin_assume_aligned
. Also see questions like How to tell GCC that a pointer argument is always double-word-aligned?
This also looks a little suspect because of the void*
. Its kind of throwing away information about the pointer. You should probably keep the information:
void doMemmove(void* pDest, const void* pSource, std::size_t sizeBytes)
{
memmove(pDest, pSource, sizeBytes);
}
Maybe something like the following:
template <typename T>
void doMemmove(T* pDest, const T* pSource, std::size_t count)
{
memmove(pDest, pSource, count*sizeof(T));
}
Another suggestion is to use new
, and stop using malloc
. Its a C++ program and GCC can make some assumptions about new
that it cannot make about malloc
. I believe some of the assumptions are detailed in GCC''s option page for the built-ins.
Yet another suggestion is to use the heap. Its always 16-byte aligned on typical modern systems. GCC should recognize it can offload to the MMX unit when a pointer from the heap is involved (sans the potential void*
and malloc
issues).
Finalmente, durante un tiempo, Clang no estaba usando las extensiones de CPU nativas cuando lo usó -march=native
. Véase, por ejemplo, Ubuntu Issue 1616723, Clang 3.4 solo anuncia SSE2 , Ubuntu Issue 1616723, Clang 3.5 solo anuncia SSE2 y Ubuntu Issue 1616723, Clang 3.6 solo anuncia SSE2 .
Es posible que algunas mejoras de la CPU en su computadora portátil basada en IvyBridge contribuyan a esta ganancia sobre los servidores basados en SandyBridge.
Prefetch de cruce de página : la CPU de su computadora portátil realizará una búsqueda previa antes de la siguiente página lineal cada vez que llegue al final de la actual, lo que le ahorrará una desagradable falla de TLB cada vez. Para intentar mitigar eso, intente construir su código de servidor para páginas de 2M / 1G.
Los esquemas de reemplazo de caché también parecen haberse mejorado (ver aquí una interesante ingeniería inversa). Si de hecho esta CPU usa una política de inserción dinámica, evitaría fácilmente que sus datos copiados intenten arruinar su Caché de último nivel (que de todos modos no puede usar de manera efectiva debido al tamaño) y guardará la habitación para otro almacenamiento en caché útil. como código, pila, datos de tablas de páginas, etc.). Para probar esto, podrías intentar reconstruir tu implementación ingenua usando cargas / tiendas de
movntdq
(movntdq
o similares, también puedes usar gcc para eso). Esta posibilidad puede explicar la caída repentina en los tamaños grandes de conjuntos de datos.Creo que también se realizaron algunas mejoras con la copia de cadenas ( here ), puede aplicarse o no aquí, dependiendo de cómo se vea el código de ensamblaje. Podrías probar el benchmarking con Dhrystone para probar si hay una diferencia inherente. Esto también puede explicar la diferencia entre memcpy y memmove.
Si pudiera obtener un servidor basado en IvyBridge o una laptop Sandy-Bridge, sería más simple probarlos todos juntos.
Esto me parece normal.
Administrar las tarjetas de memoria ECC de 8x16GB con dos CPU es una tarea mucho más difícil que una sola CPU con 2x2GB. Sus sticks de 16GB son memoria de doble cara + pueden tener búferes + ECC (incluso deshabilitados en el nivel de la placa base) ... todos los que hacen que la ruta de datos a la RAM sea mucho más larga. También tiene 2 CPU que comparten el RAM, e incluso si no hace nada en la otra CPU, siempre hay poco acceso a la memoria. Cambiar esta información requiere un tiempo adicional. Solo mire el enorme rendimiento perdido en las PC que comparten un ram con la tarjeta gráfica.
Aún así, tus servidores son realmente potentes bombas de datos. No estoy seguro de que la duplicación de 1GB ocurra muy a menudo en el software de la vida real, pero estoy seguro de que sus 128GB son mucho más rápidos que cualquier disco duro, incluso mejor SSD y aquí es donde puede aprovechar sus servidores. Hacer la misma prueba con 3GB encenderá tu computadora portátil.
Esto parece ser el ejemplo perfecto de cómo una arquitectura basada en hardware básico podría ser mucho más eficiente que los grandes servidores. ¿Cuántas PC de consumo uno podría permitirse con el dinero gastado en estos grandes servidores?
Gracias por tu pregunta muy detallada.
EDITAR: (me tomó tanto tiempo escribir esta respuesta que me perdí la parte gráfica).
Creo que el problema es dónde se almacenan los datos. ¿Puedes por favor comparar esto?
- prueba uno: asigna dos bloques contiguos de 500Mb de memoria RAM y copia de uno a otro (lo que ya has hecho)
- prueba dos: asigne 20 (o más) bloques de memoria de 500Mb y copie de la primera a la última, de modo que estén alejados el uno del otro (incluso si no puede estar seguro de su posición real).
De esta forma, verá cómo el controlador de memoria maneja bloques de memoria muy alejados entre sí. Creo que sus datos se colocan en diferentes zonas de la memoria y se requiere una operación de conmutación en algún punto de la ruta de datos para hablar con una zona y luego con la otra (existe tal problema con la memoria de doble cara).
Además, ¿se asegura de que el hilo esté vinculado a una CPU?
EDICION 2:
Hay varios tipos de delimitadores de "zonas" para la memoria. NUMA es uno, pero no es el único. Por ejemplo, dos palos laterales requieren una bandera para dirigirse a uno u otro lado. Mire en su gráfico cómo el rendimiento se degrada con gran cantidad de memoria incluso en la computadora portátil (que no tiene NUMA). No estoy seguro de esto, pero memcpy puede usar una función de hardware para copiar ram (una especie de DMA) y este chip debe tener menos memoria caché que tu CPU, esto podría explicar por qué la copia tonta con CPU es más rápida que memcpy.
La pregunta ya fue respondida above , pero en cualquier caso, aquí hay una implementación usando AVX que debería ser más rápida para copias grandes, si eso es lo que le preocupa:
#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;
}
Los números tienen sentido para mí. En realidad, hay dos preguntas aquí, y las responderé ambas.
En primer lugar, necesitamos tener un modelo mental de qué tan grande 1 transferencia de memoria funciona en algo así como un procesador Intel moderno. Esta descripción es aproximada y los detalles pueden cambiar algo de la arquitectura a la arquitectura, pero las ideas de alto nivel son bastante constantes.
- Cuando falta una carga en el caché de datos
L1
, se asigna un buffer de línea que rastreará la solicitud de falla hasta que se llene. Esto puede ser por un corto tiempo (una docena de ciclos más o menos) si golpea en la memoria cachéL2
, o mucho más (100+ nanosegundos) si falla hasta llegar a DRAM. - Hay un número limitado de estos buffers de línea por núcleo 1 , y una vez que estén completos, fallas adicionales se detendrán en espera de uno.
- Además de estos búferes de relleno utilizados para cargas / tiendas de demanda 3 , existen búferes adicionales para el movimiento de la memoria entre DRAM y L2 y cachés de nivel inferior utilizados para la recuperación previa.
El subsistema de memoria en sí tiene un límite de ancho de banda máximo , que encontrará convenientemente enumerado en ARK. Por ejemplo, el 3720QM en la computadora portátil Lenovo muestra un límite de 25.6 GB . Este límite es básicamente el producto de la frecuencia efectiva (
1600 Mhz
) multiplicada por 8 bytes (64 bits) por transferencia multiplicada por el número de canales (2):1600 * 8 * 2 = 25.6 GB/s
. El chip del servidor en la mano tiene un ancho de banda máximo de 51.2 GB / s , por socket, para un ancho de banda total del sistema de ~ 102 GB / s.A diferencia de otras características del procesador, a menudo solo hay un posible número de ancho de banda teórico en toda la variedad de chips, ya que depende solo de los valores anotados que a menudo son los mismos en muchos chips diferentes, e incluso entre arquitecturas. No es realista esperar que DRAM entregue exactamente a la tasa teórica (debido a varias inquietudes de bajo nivel, discutidas un poco here ), pero a menudo puede obtener alrededor del 90% o más.
Entonces, la consecuencia principal de (1) es que puede tratar las fallas en la RAM como un tipo de sistema de respuesta de solicitud. Una falla en DRAM asigna un buffer de relleno y el buffer se libera cuando la solicitud regresa. Solo hay 10 de estos búferes, por CPU, por fallas de demanda, lo que impone un límite estricto al ancho de banda de la memoria de demanda que una CPU puede generar, en función de su latencia.
Por ejemplo, supongamos que su E5-2680
tiene una latencia para DRAM de 80ns. Cada solicitud trae una línea de caché de 64 bytes, por lo que acabas de emitir solicitudes en serie a DRAM. Esperarías un rendimiento de 64 bytes / 80 ns = 0.8 GB/s
, y lo reducirías a la mitad otra vez (al menos ) para obtener una figura memcpy
ya que necesita leer y escribir. Afortunadamente, puede usar sus 10 buffers de relleno de línea, para que pueda superponer 10 solicitudes concurrentes a la memoria y aumentar el ancho de banda en un factor de 10, lo que lleva a un ancho de banda teórico de 8 GB / s.
Si quieres profundizar en más detalles, este hilo es oro puro. Encontrará que los hechos y las cifras de John McCalpin, también conocido como "Dr Bandwidth" serán un tema común a continuación.
Entonces, entremos en los detalles y respondamos las dos preguntas ...
¿Por qué memcpy es mucho más lento que memmove o copia enrollada a mano en el servidor?
Usted demostró que los sistemas portátiles hacen el benchmark memcpy
en aproximadamente 120 ms , mientras que las partes del servidor tardan alrededor de 300 ms . También demostraste que esta lentitud en su mayoría no es fundamental, ya que memmove
utilizar memmove
y tu memmove
enrollada a mano (en adelante, hrm
) para lograr un tiempo de aproximadamente 160 ms , mucho más cerca (pero aún más lento) del rendimiento del portátil.
Ya mostramos anteriormente que para un solo núcleo, el ancho de banda está limitado por la concurrencia y la latencia totales disponibles, en lugar del ancho de banda DRAM. Esperamos que las partes del servidor tengan una latencia más larga, pero no 300 / 120 = 2.5x
más.
La respuesta está en las tiendas de streaming (también conocidas como no temporales) . La versión de libc de memcpy
que está utilizando los usa, pero memmove
no. Lo confirmaste tanto con tu memcpy
"ingenua" que tampoco los usa, así como con mi configuración de asmlib
para usar los almacenes de transmisión (lenta) y no (rápida).
Las tiendas de transmisión dañaron los números de CPU individuales porque:
- (A) Impiden que la captación previa introduzca las líneas que se van a almacenar en el caché, lo que permite una mayor concurrencia ya que el hardware de captación previa tiene otros almacenamientos intermedios dedicados más allá de los 10 almacenamientos intermedios de llenado que exigen el uso de carga / almacenamiento.
- (B) Se sabe que el E5-2680 es particularmente lento para las tiendas de transmisión.
Ambas cuestiones se explican mejor mediante citas de John McCalpin en el hilo enlazado anterior. Sobre el tema de la efectividad de captación previa y las tiendas de transmisión , dice :
Con las tiendas "ordinarias", el precaptador de hardware L2 puede capturar líneas con anticipación y reducir el tiempo de ocupación de los búferes de relleno de línea, aumentando así el ancho de banda sostenido. On the other hand, with streaming (cache-bypassing) stores, the Line Fill Buffer entries for the stores are occupied for the full time required to pass the data to the DRAM controller. In this case, the loads can be accelerated by hardware prefetching, but the stores cannot, so you get some speedup, but not as much as you would get if both loads and stores were accelerated.
... and then for the apparently much longer latency for streaming stores on the E5, he says :
The simpler "uncore" of the Xeon E3 could lead to significantly lower Line Fill Buffer occupancy for streaming stores. The Xeon E5 has a much more complex ring structure to navigate in order to hand off the streaming stores from the core buffers to the memory controllers, so the occupancy might differ by a larger factor than the memory (read) latency.
In particular, Dr. McCalpin measured a ~1.8x slowdown for E5 compared to a chip with the "client" uncore, but the 2.5x slowdown the OP reports is consistent with that since the 1.8x score is reported for STREAM TRIAD, which has a 2:1 ratio of loads:stores, while memcpy
is at 1:1, and the stores are the problematic part.
This doesn''t make streaming a bad thing - in effect, you are trading off latency for smaller total bandwidth consumption. You get less bandwidth because you are concurrency limited when using a single core, but you avoid all the read-for-ownership traffic, so you would likely see a (small) benefit if you ran the test simultaneously on all cores.
So far from being an artifact of your software or hardware configuration, the exact same slowdowns have been reported by other users, with the same CPU.
Why is the server part still slower when using ordinary stores?
Even after correcting the non-temporal store issue, you are still seeing roughly a 160 / 120 = ~1.33x
slowdown on the server parts. What gives?
Well it''s a common fallacy that server CPUs are faster in all respects faster or at least equal to their client counterparts. It''s just not true - what you are paying for (often at $2,000 a chip or so) on the server parts is mostly (a) more cores (b) more memory channels (c) support for more total RAM (d) support for "enterprise-ish" features like ECC, virutalization features, etc 5 .
In fact, latency-wise, server parts are usually only equal or slower to their client 4 parts. When it comes to memory latency, this is especially true, because:
- The server parts have a more scalable, but complex "uncore" that often needs to support many more cores and consequently the path to RAM is longer.
- The server parts support more RAM (100s of GB or a few TB) which often requires electrical buffers to support such a large quantity.
- As in the OP''s case server parts are usually multi-socket, which adds cross-socket coherence concerns to the memory path.
So it is typical that server parts have a latency 40% to 60% longer than client parts. For the E5 you''ll probably find that ~80 ns is a typical latency to RAM, while client parts are closer to 50 ns.
So anything that is RAM latency constrained will run slower on server parts, and as it turns out, memcpy
on a single core is latency constrained. that''s confusing because memcpy
seems like a bandwidth measurement, right? Well as described above, a single core doesn''t have enough resources to keep enough requests to RAM in flight at a time to get close to the RAM bandwidth 6 , so performance depends directly on latency.
The client chips, on the other hand, have both lower latency and lower bandwidth, so one core comes much closer to saturating the bandwidth (this is often why streaming stores are a big win on client parts - when even a single core can approach the RAM bandwidth, the 50% store bandwidth reduction that stream stores offers helps a lot.
Referencias
There are lots of good sources to read more on this stuff, here are a couple.
- A detailed description of memory latency components
- Lots of memory latency results across CPUs new and old (see the
MemLatX86
andNewMemLat
) links - Detailed analysis of Sandy Bridge (and Opteron) memory latencies - almost the same chip the OP is using.
1 By large I just mean somewhat larger than the LLC. For copies that fit in the LLC (or any higher cache level) the behavior is very different. The OPs llcachebench
graph shows that in fact the performance deviation only starts when the buffers start to exceed the LLC size.
2 In particular, the number of line fill buffers has apparently been constant at 10 for several generations, including the architectures mentioned in this question.
3 When we say demand here, we mean that it is associated with an explicit load/store in the code, rather than say being brought in by a prefetch.
4 When I refer to a server part here, I mean a CPU with a server uncore . This largely means the E5 series, as the E3 series generally uses the client uncore .
5 In the future, it looks like you can add "instruction set extensions" to this list, as it seems that AVX-512
will appear only on the Skylake server parts.
6 Per little''s law at a latency of 80 ns, we''d need (51.2 B/ns * 80 ns) == 4096 bytes
or 64 cache lines in flight at all times to reach the maximum bandwidth, but one core provides less than 20.
Modifiqué el punto de referencia para usar el temporizador nsec en Linux y encontré una variación similar en diferentes procesadores, todos con memoria similar. Todos ejecutan RHEL 6. Los números son consistentes en varias ejecuciones.
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
Aquí hay resultados con el código C en línea -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
Por pura casualidad, también intenté hacer que la memcpy en línea hiciera 8 bytes a la vez. En estos procesadores Intel, no hubo una diferencia notable. Caché combina todas las operaciones de bytes en el número mínimo de operaciones de memoria. Sospecho que el código de la biblioteca gcc está tratando de ser demasiado inteligente.
[Me gustaría hacer un comentario, pero no tengo la reputación suficiente para hacerlo.]
Tengo un sistema similar y veo resultados similares, pero puedo agregar algunos puntos de datos:
- Si invierte la dirección de su
memcpy
ingenua (es decir, convierte a*p_dest-- = *p_src--
), entonces puede obtener un rendimiento mucho peor que en la dirección de avance (~ 637 ms para mí). Hubo un cambio enmemcpy()
en glibc 2.12 que expuso varios errores para llamar amemcpy
en la superposición de búferes ( http://lwn.net/Articles/414467/ ) y creo que el problema fue causado al cambiar a una versión dememcpy
que opera al revés. Por lo tanto, las copias hacia atrás y hacia adelante pueden explicar lamemcpy()
/memmove()
. - Parece ser mejor no usar tiendas no temporales. Muchas
memcpy()
optimizadas dememcpy()
cambian a almacenes no temporales (que no están en caché) para búferes grandes (es decir, más grandes que el último nivel de caché). Probé la versión de memcpy de Agner Fog ( http://www.agner.org/optimize/#asmlib ) y descubrí que era aproximadamente la misma velocidad que la versión englibc
. Sin embargo,asmlib
tiene una función (SetMemcpyCacheLimit
) que permite establecer el umbral por encima del cual se utilizan los almacenes no temporales. Establecer ese límite en 8GiB (o simplemente más grande que el buffer 1 GiB) para evitar las tiendas no temporales duplicó el rendimiento en mi caso (tiempo hasta 176 ms). Por supuesto, eso solo coincidía con el rendimiento ingenuo de la dirección hacia delante, por lo que no es estelar. - El BIOS en esos sistemas permite habilitar / deshabilitar cuatro precaptores de hardware diferentes (MLC Streamer Prefetcher, MLC Spatial Prefetcher, DCU Streamer Prefetcher y DCU IP Prefetcher). Intenté desactivar cada uno de ellos, pero al hacerlo mejor mantuve la paridad de rendimiento y reduje el rendimiento para algunos de los ajustes.
- Desactivar el límite de potencia promedio corriente (RAPL) El modo DRAM no tiene impacto.
- Tengo acceso a otros sistemas Supermicro con Fedora 19 (glibc 2.17). Con una placa Supermicro X9DRG-HF, Fedora 19 y Xeon E5-2670, veo un rendimiento similar al anterior. En una placa de socket única Supermicro X10SLM-F que ejecuta un Xeon E3-1275 v3 (Haswell) y Fedora 19, veo 9.6 GB / s para
memcpy
(104ms). La RAM en el sistema Haswell es DDR3-1600 (igual que los otros sistemas).
ACTUALIZACIONES
- Configuré la administración de energía de la CPU en Máx. Rendimiento y deshabilité el hyperthreading en el BIOS. En base a
/proc/cpuinfo
, los núcleos se sincronizaron a 3 GHz. Sin embargo, esto extrañamente disminuyó el rendimiento de la memoria en alrededor del 10%. - memtest86 + 4.10 informa ancho de banda a la memoria principal de 9091 MB / s. No pude encontrar si esto corresponde a leer, escribir o copiar.
- La prueba comparativa STREAM informa 13422 MB / s de copia, pero cuentan los bytes como leídos y escritos, por lo que corresponden a ~ 6.5 GB / s si queremos compararlos con los resultados anteriores.