Linux上memcpy性能差

78

我们最近购买了一些新服务器,但是发现在使用memcpy()函数时性能较差。相比于我们的笔记本电脑,服务器上memcpy()函数的性能要慢3倍。

服务器规格

  • 机箱和主板:SUPER MICRO 1027GR-TRF
  • CPU:2个Intel Xeon E5-2680 @ 2.70 Ghz
  • 内存:8x 16GB DDR3 1600MHz

我还在另一台规格略高的服务器上进行测试,结果与上述服务器相同。

第二台服务器规格

  • 机箱和主板:SUPER MICRO 10227GR-TRFT
  • CPU:2个Intel Xeon E5-2650 v2 @ 2.6 Ghz
  • 内存:8x 16GB DDR3 1866MHz

笔记本电脑规格

  • 机箱:Lenovo W530
  • CPU:1个Intel Core i7 i7-3720QM @ 2.6Ghz
  • 内存:4x 4GB DDR3 1600MHz

操作系统

$ 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

根据@stefan的建议,我们还测试了基于gcc 4.8.2。但是两个编译器之间没有性能差异。

测试代码

下面的测试代码是一个预先设计好的测试,用来复制我在我们的生产代码中遇到的问题。我知道这个基准测试比较简单,但它能够发现并利用我们的问题。该代码创建了两个1GB缓冲区,并在它们之间执行内存复制,计时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

从上面可以看出,在我们的服务器上,memcpy()memset()明显比在我们的笔记本电脑上慢得多。

缓冲区大小不同

我尝试过从100MB到5GB的各种缓冲区大小,但结果都差不多(服务器比笔记本电脑慢)。

NUMA亲和性

我读到有些人遇到了NUMA性能问题,于是我尝试使用numactl设置CPU和内存亲和性,但结果仍然相同。

服务器NUMA硬件:

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

笔记本NUMA硬件:

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

设置 NUMA 亲和性:

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

非常感谢任何帮助解决这个问题的援助。

编辑:GCC选项

我尝试使用不同的GCC选项进行编译:

使用将-march-mtune设置为本地的编译。

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页面

结果:完全相同的性能

编辑:Cachebench结果

为了排除我的测试程序过于简单的可能性,我下载了一个真正的基准测试程序LLCacheBench

我在每台计算机上分别构建了基准测试程序,以避免架构问题。下面是我的结果。

laptop vs server memcpy performance

请注意,在较大的缓冲区大小上性能差异非常大。测试的最后一个大小(16777216)在笔记本电脑上的性能为18849.29 MB / sec,在服务器上为6710.40。这是性能差异约为3倍。您还可以注意到,服务器的性能降低速度比笔记本电脑快得多。

编辑:memmove()在服务器上比memcpy()快2倍

根据一些实验,我尝试在我的测试用例中使用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()

我已经实现了自己天真版本的 memcpy() 并对其进行了测试:

naiveMemcpy() 源代码

(T)
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++;
  }
}

naiveMemcpy()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的建议,我尝试使用memcpy()asmlib版本。起初我的结果很差,但在将SetMemcpyCacheLimit()更改为1GB(我的缓冲区的大小)后,我以与朴素的 for 循环相当的速度运行!

坏消息是,memmove()的asmlib版本比glibc版本慢,现在在300毫秒左右运行(与memcpy()的glibc版本相当)。奇怪的是,在笔记本电脑上,当我将SetMemcpyCacheLimit()设置为一个大数字时,它会降低性能...

下面的结果中,标有SetCache的行将SetMemcpyCacheLimit设置为1073741824。没有SetCache的结果不调用SetMemcpyCacheLimit()

使用asmlib函数的结果:

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

开始倾向于缓存问题,但是是什么导致了这个问题呢?


1
你能检查一下memcpy调用的代码吗?我最初的猜测是服务器的malloc与笔记本电脑的对齐方式可能不同。 - Collin Dauphinee
2
你似乎没有使用任何特定于架构的标志进行编译,为了进行公平测试,你绝对应该这样做。话虽如此,这肯定是一个受内存限制的操作,并且看起来服务器的内存规格并不比笔记本电脑更快,因此不应该有巨大的收益。只有当服务器从缓存或寄存器中工作时,它才能胜过笔记本电脑。 - Steve Cox
1
@nick 不,你必须使用memset函数来清空页面,但是要将它们设置为其他值。 - Steve Cox
嗯,看起来你正在测量笔记本电脑的memset吞吐量约为8GB/s。这相当接近理论极限12.8。我认为memcpy需要更长的时间(不确定内存控制器是否能够独立完成所有操作)。如果memcpy实现中涉及到完整的读写操作,那么带宽将达到16 GB/s,超过了内存的理论极限。有人可以确认一下,memcpy 1GB实际上是一个2GB的操作吗? - Steve Cox
1
另一件要做的事情是编写一个简单的memcpy和memmove,并将它们编译成汇编代码进行比较,以查看在不同的机器上实现或优化是否存在显着差异。 - Salgar
显示剩余32条评论
7个回答

25

[我本想发表评论,但是声望还不够。]

我有一个类似的系统并看到了类似的结果,但可以添加一些数据点:

  • 如果反转你天真的memcpy的方向(即将其转换为*p_dest-- = *p_src--),那么性能可能比正向方向差得多(对于我来说大约为637毫秒)。在glibc 2.12中有关于调用重叠缓冲区上的memcpy时暴露了几个错误的更改(http://lwn.net/Articles/414467/),我相信该问题是由于切换到以反向方式操作的memcpy版本引起的,因此,后向与前向复制可能解释了memcpy()/memmove()的差异。
  • 似乎最好不要使用非暂态存储器。许多优化的memcpy()实现对大型缓冲区(即大于最后一级缓存)切换到非暂态存储器(不被缓存)。我测试了Agner Fog的memcpy版本(http://www.agner.org/optimize/#asmlib),发现它的速度与glibc中的版本大致相同。但是,asmlib有一个函数(SetMemcpyCacheLimit),允许设置超过非暂态存储器使用阈值。将该限制设置为8 GiB(或仅比1 GiB缓冲区更大)以避免非暂态存储器在我的案例中将性能提高了一倍(时间降至176ms)。当然,这仅匹配了正向方向天真的性能,所以并不是最佳表现。
  • 那些系统上的BIOS允许启用/禁用四种不同的硬件预取器(MLC Streamer Prefetcher,MLC Spatial Prefetcher,DCU Streamer Prefetcher和DCU IP Prefetcher)。我尝试禁用每个预取器,但这样最多只能保持性能平衡,并且会降低某些设置的性能。
  • 禁用运行平均功率限制(RAPL)DRAM模式没有影响。
  • 我可以访问运行Fedora 19(glibc 2.17)的其他Supermicro系统。在Supermicro X9DRG-HF板,Fedora 19和Xeon E5-2670 CPU上,我看到与上述类似的性能。在运行Xeon E3-1275 v3(Haswell)和Fedora 19的单插槽Supermicro X10SLM-F板上,我看到memcpy的速度为9.6 GB/s(104毫秒)。 Haswell系统上的RAM是DDR3-1600(与其他系统相同)。
  • 更新

    • 我将CPU电源管理设置为最大性能并在BIOS中禁用了超线程。根据 / proc / cpuinfo ,然后核心时钟为3 GHz。然而,这奇怪地使内存性能下降了约10%。
    • memtest86 + 4.10报告主存储器的带宽为9091 MB / s。我找不到这是否对应于读取,写入还是复制。
    • STREAM基准测试报告复制的速度为13422 MB / s,但是他们将字节计数为读取和写入,因此如果我们想要与上述结果进行比较,那么对应的速度为约6.5 GB/s。

    谢谢提供的信息。我正在阅读SuperMicro手册,并注意到BIOS中有几个“节能”设置。我想知道是否其中一个被打开了,可能会影响性能? - nick
    @nick 我明天会切换性能/效率设置。我相信将CPU缩放管理器设置为性能模式 (例如通过 echo "performance" > /sys/devices/system/cpu/cpuXX/cpufreq/scaling_governor 设置核心XX) 也会产生类似的影响。 - tbenson
    我尝试使用asmlib版本的memcpy运行我的代码,并成功地复现了你的结果。默认版本的memcpy()与glibc memcpy具有类似的性能。当将SetMemcpyCacheLimit()更改为1GB时,在服务器上,memcpy时间降至160毫秒!不幸的是,他的memmove()实现从160毫秒增加到300毫秒。这让我想到可能是某种缓存问题。 - nick
    使用asmlib版本的memmove和memcpy更新了我的结果。 - nick
    1
    memtest86+ 应该打印COPY速度 - memtest86+-4.20-1.1/init.c line 1220 使用 memspeed((ulong)mapping(0x100), i*1024, 50, MS_COPY) 调用。而 memspeed() 本身是通过 cld; rep movsl 实现的,它在内存段上进行了50次复制循环。 - osgx
    显示剩余2条评论

    10

    这看起来对我来说很正常。

    管理两个CPU的8x16GB ECC内存条要比管理一个CPU的2x2GB内存条更加困难。你的16GB内存条是双面内存+可能有缓存+ECC(即使在主板上被禁用)...所有这些都会使数据通向RAM变得更加复杂。你还有两个CPU共享内存,即使在另一个CPU上什么都不做,也总会有一点内存访问。切换这些数据需要额外的时间。只需看看那些与显卡共享部分内存的PC所失去的巨大性能即可。

    不过,你的服务器确实是非常强大的数据泵。我不确定在现实生活中软件中经常重复1GB,但我肯定你的128GB比任何硬盘,甚至最好的SSD都要快得多,这就是你可以利用你的服务器的地方。用3GB做相同的测试将会让你的笔记本电脑着火。

    这似乎是一个很好的例子,说明基于普通硬件的架构可能比大型服务器更加高效。用花在这些大型服务器上的钱,可以购买多少消费级PC呢?

    谢谢您的详细问题。

    编辑:(我花了很长时间来写这个答案,结果错过了图表部分。)

    我认为问题在于数据存储的位置。你可以比较一下以下两种情况:

    • 测试一:分配两个500MB连续块的内存并从一个复制到另一个(你已经完成了这个测试)
    • 测试二:分配20(或更多)个500MB的内存块,并将其从第一个复制到最后一个,以便它们相互远离(即使你无法确定它们的实际位置)。

    这样,你就可以看到内存控制器如何处理远离彼此的内存块。我认为你的数据被放在不同的内存区域中,并且在数据通向RAM的某个点上需要进行切换操作(双面内存存在这样的问题)。

    此外,你是否确保线程绑定到一个CPU上?

    编辑2:

    内存有几种“区域”分隔符。 NUMA(非一致性内存访问)是其中之一,但不是唯一的一种。例如,双面棒需要标志以寻址一侧或另一侧。观察您的图表,即使在笔记本电脑上(没有NUMA),也会看到大块内存的性能下降。

    我不确定,但memcpy(内存复制函数)可能使用硬件功能来复制内存(一种DMA),并且此芯片的缓存比您的CPU要少,这可能解释了为什么使用CPU进行简单复制比使用memcpy更快。


    3
    ECC和缓存开销,以及可能不同的CAS延迟,是解释小缓冲区大小下约3%差异的好理由。但我认为问题的主要关注点是图表最右侧,在那里性能相差三倍。 - Ben Voigt
    2
    这并没有解释为什么系统memcpy的性能比naiveMemcpy差。https://dev59.com/4mLVa4cB1Zd3GeqP0ufF#10300382用NUMA在Supermicro板上进行了解释。 我也同意1x I7比2x I5更快的解释。首先,1x比2x更快,而且I7比I5有更好的缓存。 - rurban
    @bokan 我正在使用 numactl 确保所有东西都在同一个 CPU 和 NUMA 控制器上运行。这将把进程绑定到我指定的 CPU 和 NUMA 控制器。我已经使用 numactl --hardware 命令验证它们是否连接在一起。 - nick

    8
    你的基于IvyBridge的笔记本电脑可能有一些CPU改进,这有助于超过基于SandyBridge的服务器。以下是一些可能的原因:
    1. 页面交叉预取 - 每当你到达当前页面的末尾时,你的笔记本电脑中央处理器都会预取下一个线性页面,这样可以避免每次TLB错误。为了减轻这种情况,尝试将服务器代码构建为2M/1G页面。
    2. 缓存置换方案似乎也得到了改进(参见此处)。如果该CPU确实使用动态插入策略,它就可以轻松防止复制的数据尝试破坏最后级缓存(由于大小而无法有效使用),并保存其他有用缓存(如代码、堆栈、页表数据等)。为了测试这一点,可以尝试使用流式加载/存储(movntdq或类似的指令,也可以使用gcc内置函数)。这种可能性可能解释了大型数据集大小突然下降的原因。
    3. 我认为还对字符串复制进行了一些改进(此处),这取决于你的汇编代码的样子。你可以尝试使用Dhrystone 进行基准测试,看是否存在固有差异。这也可能解释了memcpy和memmove之间的差异。
    如果你能得到一个基于IvyBridge的服务器或基于Sandy-Bridge的笔记本电脑,测试所有这些东西将变得更加简单。

    2
    在我的帖子顶部,我报告了两个服务器的规格。服务器1是SandyBridge E5-2680,服务器2是IvyBridge E5-2650v2。这两台服务器显示出相同的性能指标。 - nick
    @nick,嗯,错过了v2部分。你会认为他们会使名称更加可区分...好的,我改正了,尽管第二个要点在服务器和客户端产品之间可能看起来和行为不同,因为它们具有完全不同的“非核心”,所以仍然可能适用。 - Leeor
    @Leeor - 顺便说一下,使用2MB或1G页面并不能解决预取问题:预取逻辑仍然以4K粒度运行,实际上它主要查看物理地址(即,它不知道当前流恰好位于2MB页面中,因此它不会预取超过4K边界)。尽管如此,最近在常青藤桥上,有一个“下一页预取器”,它试图通过快速重新启动预取来部分地解决这个问题,当访问进入下一页时。目前还不清楚它如何与2MB页面交互。 - BeeOnRope

    4

    这些数字对我来说很有意义。实际上,这里有两个问题,我会回答它们。

    不过首先,我们需要心理模型来了解在像现代英特尔处理器这样的设备上,大内存传输是如何工作的。这个描述是近似的,细节可能会因架构而异,但高层次的思想是相当恒定的。

    当一个负载在L1数据缓存中未命中时,将分配一个行缓冲区来跟踪该未命中请求,直到填充为止。如果它在L2高速缓存中命中,则可能只需要很短的时间(约十几个周期),否则需要更长的时间(100+纳秒)才能完全未命中到DRAM。
    每个核心有一定数量的这些行缓冲区,一旦它们满了,进一步的未命中请求将会停滞等待。
    除了用于需求式加载/存储的这些填充缓冲区之外,还有用于DRAM和L2以及较低级别高速缓存之间的内存移动的其他缓冲区,这些缓冲区由预取操作使用。
    内存子系统本身具有最大带宽限制,你可以方便地在ARK上找到它。例如,联想笔记本中的3720QM显示出一个25.6 GB的限制。这个限制基本上是有效频率(1600 Mhz)乘以8字节(64位)每次传输的产品乘以通道数(2):1600 * 8 * 2 = 25.6 GB/s。服务器芯片的峰值带宽为51.2 GB/s,每个插槽,总系统带宽约为102 GB/s。
    与其他处理器特性不同,通常只有一些可能的理论带宽数字适用于各种芯片,因为它仅取决于已注明的值,这些值在许多不同的芯片上甚至跨架构都是相同的。由于各种低级别问题(在此处here中讨论),实际情况下不能确切地期望DRAM按照理论速率提供服务,但你通常可以获得90%或更高的效率。
    因此,(1)的主要影响是您可以将对RAM的未命中视为一种请求响应系统。未命中DRAM会分配一个“填充缓冲区”,并在请求返回时释放该缓冲区。每个CPU针对需求未命中仅有10个这样的缓冲区,这对于单个CPU可以生成的需求内存带宽有严格限制,作为其延迟函数。
    例如,假设您的E5-2680对DRAM的延迟为80ns。每个请求带来一个64字节的高速缓存行,因此如果您只按顺序向DRAM发出请求,则期望吞吐量为微不足道的64字节/ 80ns = 0.8 GB / s,并且您需要再次将其减半(至少)以获得memcpy数字,因为它需要读取和写入。幸运的是,您可以使用10个线路填充缓冲区,因此可以重叠10个并发请求到内存并将带宽增加10倍,从而实现理论带宽为8 GB / s。

    如果您想深入了解更多细节,这个帖子是非常有价值的。您将在下面看到John McCalpin,又名“Dr Bandwidth”的事实和数据。

    那么让我们深入了解细节并回答两个问题...

    为什么服务器上的memcpy比memmove或手动复制慢得多?

    您展示了笔记本电脑系统在约120毫秒内完成memcpy基准测试,而服务器部件需要约300毫秒。您还表明,这种缓慢大多不是根本性的,因为您能够使用memmove和您自己编写的手动复制(以下简称hrm)来达到约160毫秒的时间,这已经接近(但仍然比)笔记本电脑的性能慢一些。

    我们已经表明对于单个核心,带宽受总并发性和延迟的限制,而不是DRAM带宽。我们预计服务器部件的延迟可能会更长,但不会比原来多300 / 120 = 2.5倍
    答案在于流式存储(也称非临时性存储)。您使用的memcpy的libc版本使用它们,但memmove则不使用。您通过“天真”的memcpy以及我配置asmlib来确认了这一点,其中一个使用流式存储(慢),另一个不使用(快)。
    流式存储会损害单个CPU数字,因为:
    • (A) 它们防止预取将要存储的行带入缓存中,这样可以更多地并发,因为预取硬件有其他专用缓冲区,超过了10个填充缓冲区,这些缓冲区需要使用负载/存储。
    • (B) E5-2680以特别慢的速度进行流式存储。

    以上链接的John McCalpin的引用更好地解释了这两个问题。关于预取效果和流式存储他说道

    使用“普通”存储器,L2硬件预取器可以提前获取行数据,减少线填充缓冲区的占用时间,从而增加持续带宽。另一方面,使用流式(绕过缓存)存储器时,存储器的线填充缓冲区条目将被占用,直到数据传递到DRAM控制器所需的全部时间。在这种情况下,加载可以通过硬件预取进行加速,但存储则不行,因此您会得到一些加速效果,但比如果加载和存储都加速的话,加速效果会小。

    ... 然后对于E5上流式存储器明显更长的延迟,他说

    Xeon E3的简单“uncore”可能会导致流式存储器的Line Fill Buffer占用显著降低。相比之下,Xeon E5具有更复杂的环形结构,以便于从核心缓冲区将流式存储器移交给内存控制器,因此占用率可能会比内存(读取)延迟有更大的差异。
    特别地,McCalpin博士测量了E5相对于具有“客户端”uncore芯片的~1.8倍减速,但OP报告的2.5倍减速与此一致,因为1.8倍得分是针对STREAM TRIAD报告的,该报告具有2:1的负载:存储比率,而memcpy是1:1,存储是有问题的部分。
    这并不意味着流式传输是一件坏事 - 实际上,您正在通过交换延迟来实现更小的总带宽消耗。当使用单个核心时,带宽较少,因为您受到并发限制,但是您避免了所有读取所有权流量,因此如果您同时在所有核心上运行测试,则可能会看到(小)好处。
    迄今为止,完全相同的减速已由其他用户报告,使用相同的CPU,并非您软件或硬件配置的产物。

    为什么使用普通存储器时,服务器部分仍然比较慢?

    即使纠正了非临时存储问题,您仍然会看到服务器部分大约减速了1.33倍(160/120 = 1.33x)。出现了什么问题呢?

    实际上,一个常见的谬论是,服务器CPU在所有方面都比客户端更快或至少相等。这只是不真实的 - 您在服务器部件上支付的(通常约2000美元一颗芯片)主要是(a) 更多核心 (b) 更多内存通道 (c) 支持更多总RAM (d) 支持“企业级”功能,如ECC、虚拟化功能等5

    事实上,就延迟而言,服务器部件通常只等于或更慢于客户端4部件。当涉及内存延迟时,情况尤其如此,因为:

    • 服务器部件具有更可扩展但更复杂的“不核心”部分,通常需要支持更多的内核,因此到RAM的路径更长。
    • 服务器部件支持更多的RAM(100 GB或几TB),这通常需要电气缓冲器来支持这么大的数量。
    • 与OP的情况一样,服务器部件通常是多插槽的,这会给内存路径添加跨插槽的一致性问题。

    因此,服务器部件的延迟通常比客户端部件长40%至60%。对于E5,您可能会发现到RAM的典型延迟约为80 ns,而客户端部件则更接近50 ns。

    所以,任何受RAM延迟限制的内容都会在服务器部件上运行得更慢,而事实证明,在单个核心上进行的memcpy是受延迟限制的。这很令人困惑,因为memcpy似乎是带宽测量,对吧?嗯,如上所述,单个核心没有足够的资源同时处理足够多的请求到RAM,以接近RAM带宽6,因此性能直接取决于延迟。
    另一方面,客户端芯片具有较低的延迟和较低的带宽,因此一个核心可以更接近饱和带宽(这通常是为什么流式存储在客户端部件上非常成功的原因 - 即使单个核心也可以接近RAM带宽,流式存储提供的50%存储带宽减少可以帮助很多)。

    参考文献

    有很多好的来源可以阅读更多关于这方面的内容,以下是其中的一些。


    1 “大”指的是比LLC略大一些。对于适合LLC(或任何更高缓存级别)的副本,行为非常不同。OP的llcachebench图表显示,实际上性能偏差只有在缓冲区开始超过LLC大小时才开始。

    2 特别地,包括在此问题中提到的架构在内,线路填充缓冲区的数量已经保持不变了好几代。

    3 当我们在这里说“需求”时,我们指的是与代码中的显式加载/存储相关联,而不是通过预取来带入。

    4 在这里我所说的服务器部分,指的是具有服务器uncore的CPU。这主要是指E5系列,因为E3系列通常使用客户端uncore

    5 在未来,似乎可以将“指令集扩展”添加到此列表中,因为它似乎只会出现在Skylake服务器部件上的AVX-512

    6 根据小定理,在80 ns的延迟下,我们需要(51.2 B/ns * 80 ns) == 4096 bytes或64个缓存行同时运行以达到最大带宽,但一个核心提供不到20个。


    4

    我修改了基准测试程序,使用Linux中的nsec计时器,并发现在不同处理器上有类似的变化,所有处理器的内存都相似。这些处理器都运行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个字节。在这些英特尔处理器上,这并没有明显的区别。缓存将所有字节操作合并为最少的存储器操作。我怀疑gcc库代码试图过于聪明。


    3

    该问题已在上面得到解答,但无论如何,以下是一种使用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;
    }
    

    0

    服务器1规格

    • CPU:2x Intel Xeon E5-2680 @ 2.70 Ghz

    服务器2规格

    • CPU:2x Intel Xeon E5-2650 v2 @ 2.6 Ghz

    根据Intel ARK,E5-2650E5-2680都具有AVX扩展。

    CMake文件构建

    这是你的问题之一。CMake为您选择了一些相当糟糕的标志。您可以通过运行make VERBOSE=1来确认它。

    你应该在你的 CFLAGSCXXFLAGS 中添加 -march=native-O3。这样可以显著提高性能,同时也会启用 AVX 扩展。如果没有 -march=XXX,则实际上只能得到一个最小的 i686 或 x86_64 机器。如果没有 -O3,则无法启用 GCC 的向量化。

    我不确定 GCC 4.6 是否支持 AVX(以及类似 BMI 的扩展)。我知道 GCC 4.8 或 4.9 支持,因为我曾经追踪过一个对齐错误,导致当 GCC 将 memcpy 和 memset 外包给 MMX 单元时会导致段错误。AVX 和 AVX2 允许 CPU 一次处理 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字节对齐的。当涉及来自堆的指针时(除了潜在的void*malloc问题),GCC应该能够识别并卸载到MMX单元。

    最后,有一段时间,当使用-march=native时,Clang没有使用本地CPU扩展。例如,请参见Ubuntu Issue 1616723, Clang 3.4 only advertises SSE2, Ubuntu Issue 1616723, Clang 3.5 only advertises SSE2Ubuntu Issue 1616723, Clang 3.6 only advertises SSE2


    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接