Nous avons récemment acheté de nouveaux serveurs et les performances de memcpy sont faibles. Les performances de memcpy sont 3x plus lentes sur les serveurs que sur nos ordinateurs portables.
Spécifications du serveur
- Chassis et Mobo : SUPER MICRO 1027GR-TRF
- CPU : 2x Intel Xeon E5-2680 @ 2.70 Ghz
- Mémoire : 8x 16GB DDR3 1600MHz
Edit : Je teste également sur un autre serveur avec des spécifications légèrement supérieures et je vois les mêmes résultats que sur le serveur ci-dessus.
Spécifications du serveur 2
- Chassis et Mobo : SUPER MICRO 10227GR-TRFT
- Processeur : 2x Intel Xeon E5-2650 v2 @ 2.6 Ghz
- Mémoire : 8x 16GB DDR3 1866MHz
Spécifications des ordinateurs portables
- Châssis : Lenovo W530
- CPU : 1x Intel Core i7 i7-3720QM @ 2.6Ghz
- Mémoire : 4x 4GB DDR3 1600MHz
Système d'exploitation
$ 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
Compilateur (sur tous les systèmes)
$ gcc --version
gcc (GCC) 4.6.1
J'ai également testé avec gcc 4.8.2 suite à une suggestion de @stefan. Il n'y avait aucune différence de performance entre les compilateurs.
Code d'essai Le code de test ci-dessous est un test automatique pour reproduire le problème que je vois dans notre code de production. Je sais que ce benchmark est simpliste mais il a permis d'exploiter et d'identifier notre problème. Le code crée deux tampons de 1GB et les memcpys entre eux, en synchronisant l'appel memcpy. Vous pouvez spécifier d'autres tailles de tampon sur la ligne de commande en utilisant : ./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;
}
Fichier CMake à construire
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})
Résultats des tests
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
Comme vous pouvez le constater, les memcpys et memsets de nos serveurs sont beaucoup plus lents que les memcpys et memsets de nos ordinateurs portables.
Variation de la taille des tampons
J'ai essayé des tampons de 100 Mo à 5 Go avec des résultats similaires (serveurs plus lents que l'ordinateur portable).
Affinité NUMA
J'ai lu que des personnes avaient des problèmes de performance avec NUMA, j'ai donc essayé de définir l'affinité du CPU et de la mémoire en utilisant numactl mais les résultats sont restés les mêmes.
Matériel NUMA pour serveurs
$ 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
Matériel NUMA pour ordinateurs portables
$ 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
Réglage de l'affinité NUMA
$ numactl --cpunodebind=0 --membind=0 ./big_memcpy_test
Toute aide pour résoudre ce problème est la bienvenue.
Editer : Options GCC
Sur la base des commentaires, j'ai essayé de compiler avec différentes options GCC :
Compilation avec -march et -mtune en mode natif
g++ -std=c++0x -Wall -march=native -mtune=native -O3 -DNDEBUG -o big_memcpy_test main.cpp
Résultat : Exactement les mêmes performances (aucune amélioration)
Compilation avec -O2 au lieu de -O3
g++ -std=c++0x -Wall -march=native -mtune=native -O2 -DNDEBUG -o big_memcpy_test main.cpp
Résultat : Exactement les mêmes performances (aucune amélioration)
Edit : Changed memset to write 0xF instead of 0 to avoid NULL page (@SteveCox)
Pas d'amélioration lors du memsetting avec une valeur autre que 0 (utilisé 0xF dans ce cas).
Edit : Résultats de Cachebench
Afin d'exclure que mon programme de test soit trop simpliste, j'ai téléchargé un vrai programme de benchmarking LLCacheBench ( http://icl.cs.utk.edu/projects/llcbench/cachebench.html )
J'ai construit le benchmark sur chaque machine séparément pour éviter les problèmes d'architecture. Voici mes résultats.
Remarquez la TRES grande différence de performance sur les plus grandes tailles de tampon. La dernière taille testée (16777216) a atteint 18849.29 MB/sec sur l'ordinateur portable et 6710.40 sur le serveur. Cela représente une différence de performance de 3x. Vous pouvez également remarquer que la baisse de performance sur le serveur est beaucoup plus importante que sur l'ordinateur portable.
Edit : memmove() est 2x plus rapide que memcpy() sur le serveur.
Sur la base de quelques expérimentations, j'ai essayé d'utiliser memmove() au lieu de memcpy() dans mon cas de test et j'ai trouvé une amélioration de 2x sur le serveur. Memmove() sur l'ordinateur portable est plus lent que memcpy() mais curieusement, il fonctionne à la même vitesse que memmove() sur le serveur. Cela nous amène à la question suivante : pourquoi memcpy est-il si lent ?
Mise à jour du code pour tester memmove avec memcpy. J'ai dû envelopper le memmove() à l'intérieur d'une fonction parce que si je le laissais en ligne, GCC l'optimisait et exécutait exactement la même chose que memcpy() (je suppose que gcc l'a optimisé pour memcpy parce qu'il savait que les emplacements ne se chevauchaient pas).
Résultats actualisés
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
Editer : Memcpy naïf
Sur la base de la suggestion de @Salgar, j'ai implémenté ma propre fonction memcpy naïve et l'ai testée.
Memcpy naïf 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++;
}
}
Résultats de Memcpy naïf comparé à memcpy()
Buffer Size: 1GB | memcpy (ms) | memmove(ms) | naiveMemcpy()
------------------------------------------------------------
Laptop 1 | 113 | 161 | 160
Server 1 | 301 | 159 | 159
Server 2 | 325 | 159 | 159
Edit : Sortie d'assemblage
Source simple de 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;
}
Sortie d'assemblage : C'est exactement la même chose sur le serveur et l'ordinateur portable. J'économise de l'espace et ne colle pas les deux.
.file "main_memcpy.cpp"
.section .text.startup,"ax",@progbits
.p2align 4,,15
.globl main
.type main, @function
main:
.LFB25:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movl $1073741824, %edi
pushq %rbx
.cfi_def_cfa_offset 24
.cfi_offset 3, -24
subq $8, %rsp
.cfi_def_cfa_offset 32
call malloc
movl $1073741824, %edi
movq %rax, %rbx
call malloc
movl $1073741824, %edx
movq %rax, %rbp
movl $10, %esi
movq %rbx, %rdi
call memset
movl $1073741824, %edx
movl $15, %esi
movq %rbp, %rdi
call memset
movl $1073741824, %edx
movq %rbx, %rsi
movq %rbp, %rdi
call memcpy
movq %rbp, %rdi
call free
movq %rbx, %rdi
call free
addq $8, %rsp
.cfi_def_cfa_offset 24
xorl %eax, %eax
popq %rbx
.cfi_def_cfa_offset 16
popq %rbp
.cfi_def_cfa_offset 8
ret
.cfi_endproc
.LFE25:
.size main, .-main
.ident "GCC: (GNU) 4.6.1"
.section .note.GNU-stack,"",@progbits
PROGRESS !!!! asmlib
Sur la base de la suggestion de @tbenson, j'ai essayé d'exécuter avec la fonction asmlib version de memcpy. Au départ, mes résultats étaient médiocres, mais après avoir changé SetMemcpyCacheLimit() à 1GB (taille de mon tampon), je fonctionnais à une vitesse comparable à celle de ma boucle for naïve !
La mauvaise nouvelle est que la version asmlib de memmove est plus lente que la version glibc, elle tourne maintenant autour de 300 ms (à égalité avec la version glibc de memcpy). Ce qui est étrange, c'est que sur l'ordinateur portable, lorsque je fixe la valeur de la fonction SetMemcpyCacheLimit() à un grand nombre, les performances s'en ressentent...
Dans les résultats ci-dessous, les lignes marquées avec SetCache ont SetMemcpyCacheLimit défini à 1073741824. Les résultats sans SetCache n'appellent pas SetMemcpyCacheLimit().
Résultats utilisant les fonctions 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
Je commence à penser à un problème de cache, mais quelle en serait la cause ?