【问题标题】:Poor memcpy Performance on LinuxLinux 上的 memcpy 性能不佳
【发布时间】:2014-05-12 16:28:40
【问题描述】:

我们最近购买了一些新服务器,但 memcpy 性能不佳。与我们的笔记本电脑相比,服务器上的 memcpy 性能要慢 3 倍。

服务器规格

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

编辑:我也在另一台规格稍高的服务器上进行测试,并看到与上述服务器相同的结果

服务器 2 规格

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

笔记本电脑规格

  • 机箱:联想 W530
  • CPU:1x 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,对 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 慢得多。

不同的缓冲区大小

我尝试了从 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 选项

基于 cmets,我尝试使用不同的 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)

使用 0 以外的值进行 memsetting 时没有改进(在这种情况下使用 0xF)。

编辑:Cachebench 结果

为了排除我的测试程序过于简单,我下载了一个真正的基准测试程序 LLCacheBench (http://icl.cs.utk.edu/projects/llcbench/cachebench.html)

我在每台机器上分别构建了基准测试以避免架构问题。以下是我的结果。

请注意,非常大的差异在于较大缓冲区大小的性能。最后测试的大小 (16777216) 在笔记本电脑上以 18849.29 MB/秒的速度运行,在服务器上以 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

根据@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() 更改为 1GB(我的缓冲区大小)后,我的运行速度与我幼稚的 for 循环相当!

坏消息是 memmove 的 asmlib 版本比 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

开始倾向于缓存问题,但这会导致什么?

【问题讨论】:

  • 你是在服务器上编译测试吗?
  • 你能检查一下它为 memcpy 调用的代码吗?我最初的猜测是,服务器的 malloc 可能与笔记本电脑的对齐方式不同。
  • 您似乎没有使用任何特定于拱门的标志进行编译,您绝对应该对此进行公平测试。话虽如此,这绝对是一个内存受限的操作,而且看起来服务器上的内存规格并不是真的更快,所以应该不会有很大的收益。仅当服务器从缓存或寄存器中工作时,服务器才能胜过笔记本电脑
  • @nick 不,你必须 memset 页面,但将它们设置为其他值
  • 另外就是写一个简单的memcpy和memmove,编译下来,对比一下两者的汇编,看看在不同机器上的实现或优化是否有显着差异。

标签: c++ c linux memcpy numa


【解决方案1】:

[我会将此作为评论,但没有足够的声誉这样做。]

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

  • 如果你反转你幼稚的memcpy 的方向(即转换为*p_dest-- = *p_src--),那么你可能会得到比正向更差的性能(对我来说约为637 ms)。 glibc 2.12 中的memcpy() 发生了变化,这暴露了在重叠缓冲区(http://lwn.net/Articles/414467/)上调用memcpy 的几个错误,我相信这个问题是由切换到向后运行的memcpy 版本引起的。因此,向后复制与向前复制可以解释memcpy()/memmove() 的差异。
  • 似乎最好不要使用非临时存储。许多优化的memcpy() 实现切换到大缓冲区(即大于最后一级缓存)的非临时存储(未缓存)。我测试了 Agner Fog 的 memcpy 版本(http://www.agner.org/optimize/#asmlib),发现它的速度与glibc 中的版本大致相同。但是,asmlib 有一个功能 (SetMemcpyCacheLimit),允许设置使用非临时存储的阈值。将该限制设置为 8GiB(或仅大于 1 GiB 缓冲区)以避免非临时存储在我的情况下使性能翻倍(时间降至 176 毫秒)。当然,这仅与正向幼稚性能相匹配,因此并不出色。
  • 这些系统上的 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 (104ms) 的速度为 9.6 GB/s。 Haswell 系统上的 RAM 为 DDR3-1600(与其他系统相同)。

更新

  • 我在 BIOS 中将 CPU 电源管理设置为 Max Performance 并禁用了超线程。基于/proc/cpuinfo,内核的时钟频率为 3 GHz。然而,这奇怪地降低了大约 10% 的内存性能。
  • memtest86+ 4.10 报告到主内存的带宽为 9091 MB/s。我找不到这是否对应于读取、写入或复制。
  • STREAM benchmark 报告的复制速度为 13422 MB/s,但它们将字节数计为读取和写入的字节数,因此如果我们想与上述结果进行比较,这相当于 ~6.5 GB/s。

【讨论】:

  • 感谢您的信息。我正在阅读 SuperMicro 手册,并注意到 BIOS 中的几个“能效”设置。我想知道其中一个是否恰好打开了 na 可能会损害性能?
  • @nick 我明天将切换性能/效率设置。我相信将 CPU 缩放调节器设置为性能模式(例如,通过 echo "performance" &gt; /sys/devices/system/cpu/cpuXX/cpufreq/scaling_governor 用于核心 XX)也会产生类似的影响。
  • 我尝试使用 asmlib 版本的 memcpy 运行我的代码,并且能够重现您的结果。 memcpy() 的默认版本与 glibc memcpy 具有相似的性能。将 SetMemcpyCacheLimit() 更改为 1GB 时,服务器上的 memcpy 时间降至 160 毫秒!不幸的是,他的 memmove() 实现从 160 毫秒增加到 300 毫秒。这让我觉得这是某种缓存问题。
  • 使用 asmlib 版本的 memmove 和 memcpy 更新了我的结果。
  • memtest86+ 应该打印 COPY speed - memtest86+-4.20-1.1/init.c line 1220 使用 memspeed((ulong)mapping(0x100), i*1024, 50, MS_COPY) 调用。而memspeed() 本身是用cld; rep movsl 实现的,在内存段上进行了 50 次复制循环迭代。
【解决方案2】:

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

管理具有两个 CPU 的 8x16GB ECC 记忆棒比具有 2x2GB 的单个 CPU 困难得多。您的 16GB 记忆棒是双面内存 + 它们可能有缓冲区 + ECC(甚至在主板级别禁用)......所有这些都使 RAM 的数据路径更长。您还有 2 个 CPU 共享内存,即使您在另一个 CPU 上什么也不做,也总是很少有内存访问。切换此数据需要一些额外的时间。看看在与显卡共享一些内存的 PC 上损失的巨大性能。

您的服务器仍然是非常强大的数据泵。我不确定在现实生活中的软件中是否经常复制 1GB,但我确信你的 128GB 比任何硬盘驱动器快得多,甚至是最好的 SSD,这是你可以利用服务器的地方。用 3GB 做同样的测试会让你的笔记本电脑着火。

这看起来是一个完美的例子,说明基于商品硬件的架构如何比大型服务器更高效。花在这些大型服务器上的钱能买多少个人电脑?

感谢您提出非常详细的问题。

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

我认为问题在于数据的存储位置。你能比较一下吗:

  • 测试一:分配两个 500Mb 内存的连续块并从一个复制到另一个(您已经完成了)
  • 测试二:分配 20 个(或更多)500Mb 内存块并从第一个到最后一个复制,因此它们彼此远离(即使您无法确定它们的真实位置)。

通过这种方式,您将看到内存控制器如何处理彼此远离的内存块。我认为您的数据被放置在不同的内存区域中,它需要在数据路径上的某个点进行切换操作才能与一个区域通信,然后再与另一个区域通信(双面内存存在这样的问题)。

另外,您是否确保线程绑定到一个 CPU?

编辑 2:

内存有几种“区域”分隔符。 NUMA 是一个,但不是唯一一个。例如,两侧的棍子需要一个标志来处理一侧或另一侧。在您的图表上查看即使在笔记本电脑上(没有 NUMA),性能如何随着大块内存而下降。 我不确定这一点,但 memcpy 可能使用硬件功能来复制 ram(一种 DMA),并且该芯片的缓存必须比您的 CPU 少,这可以解释为什么使用 CPU 进行哑副本比 memcpy 更快。

【讨论】:

  • ECC 和缓冲开销,以及可能不同的 CAS 延迟,很好地解释了小缓冲区大小时约 3% 的差异。但我认为这个问题的主要关注点是图表的最右侧,那里的性能相差三倍。
  • 这并不能解释与 naiveMemcpy 相比系统 memcpy 性能差的原因。 stackoverflow.com/a/10300382/414279 在 Supermicro 板上用 NUMA 解释它。我也为 1x I7 比 2x I5 解释更快。第一个 1x 比 2x 快,I7 比 I5 有更好的缓存。
  • @bokan 我正在使用 numactl 确保一切都在同一个 CPU 和 NUMA 控制器上运行。这会将进程绑定到我指定的 CPU 和 NUMA 控制器。我已经使用 numactl --hardware 命令验证了它们是连接在一起的。
【解决方案3】:

与基于 SandyBridge 的服务器相比,基于 IvyBridge 的笔记本电脑中的一些 CPU 改进可能有助于实现这一点。

  1. Page-crossing Prefetch - 只要您到达当前页面的末尾,您的笔记本电脑 CPU 就会提前预取下一个线性页面,从而每次都为您节省一个讨厌的 TLB 未命中。要尝试缓解这种情况,请尝试为 2M / 1G 页面构建服务器代码。

  2. 缓存替换方案似乎也得到了改进(参见有趣的逆向工程here)。如果这个 CPU 确实使用了动态插入策略,它会很容易地阻止您复制的数据试图破坏您的 Last-Level-Cache(由于大小,它无论如何都无法有效使用),并为其他有用的缓存节省空间如代码、堆栈、页表数据等)。要对此进行测试,您可以尝试使用流式加载/存储(movntdq 或类似的,您也可以使用 gcc 内置)重建您的幼稚实现。这种可能性可以解释大型数据集大小的突然下降。

  3. 我相信字符串复制也进行了一些改进 (here),它可能适用也可能不适用于这里,具体取决于您的汇编代码的样子。您可以尝试使用Dhrystone 进行基准测试,以测试是否存在固有差异。这也可以解释 memcpy 和 memmove 的区别。

如果您可以使用基于 IvyBridge 的服务器或 Sandy-Bridge 笔记本电脑,那么将所有这些一起测试是最简单的。

【讨论】:

  • 在我的帖子顶部,我报告了两台服务器的规格。服务器 1 是 SandyBridge E5-2680,服务器 2 是 IvyBridge E5-2650v2。两台服务器表现出相同的性能数据。
  • @nick,嗯,错过了 v2 部分。您可能会认为它们会使名称更容易区分......好吧,我的立场是正确的,尽管第二个项目符号在服务器和客户端产品之间的外观和行为可能非常不同,因为它们具有完全不同的“非核心”,所以它仍然可能适用。
  • @Leeor - FWIW,使用 2MB 或 1G 页面并不能解决预取问题:预取逻辑仍然以 4K 粒度运行,实际上它主要查看物理地址(即,它没有知道当前流恰好位于 2MB 页面中,因此它不会预取超过 4K 边界)。也就是说,最近在 Ivy Bridge 中,有一个“下一页预取器”试图通过在访问进入下一页时快速重新开始预取来至少部分解决这个问题。目前尚不清楚它如何与 2MB 页面交互。
【解决方案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 库代码太聪明了。

【讨论】:

    【解决方案5】:

    above 已经回答了这个问题,但无论如何,如果您担心的话,这里有一个使用 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;
    }
    

    【讨论】:

      【解决方案6】:

      这些数字对我来说很有意义。这里其实有两个问题,我都会一一解答。

      不过,首先,我们需要有一个心智模型,了解在现代英特尔处理器等设备上进行多大的1 内存传输。这个描述是近似的,细节可能会因架构而有所不同,但高层次的想法是相当一致的。

      1. L1 数据缓存中的加载未命中时,将分配一个行缓冲区,该缓冲区将跟踪未命中请求,直到它被填满。如果它在L2 缓存中命中,则可能会持续很短的时间(十几个周期左右),如果一直未命中到 DRAM,则可能会更长(100+ 纳秒)。
      2. 每个内核1的这些行缓冲区数量有限,一旦它们已满,更多的未命中将停止等待一个。
      3. 除了这些用于需求3加载/存储的填充缓冲区之外,还有用于 DRAM 和 L2 之间的内存移动以及预取使用的较低级别缓存的额外缓冲区。李>
      4. 内存子系统本身有一个最大带宽限制,您可以在 ARK 上方便地找到该限制。例如,联想笔记本电脑中的 3720QM 显示限制为25.6 GB。这个限制基本上是每次传输的有效频率 (1600 Mhz) 乘以 8 字节(64 位)乘以通道数 (2) 的乘积:1600 * 8 * 2 = 25.6 GB/s。手上的服务器芯片每个插槽的峰值带宽为51.2 GB/s,总系统带宽约为 102 GB/s。

        与其他处理器功能不同,各种芯片通常只有一个可能的理论带宽数,因为 它仅取决于在许多情况下通常相同的注意值 不同的芯片,甚至跨架构。这是不现实的 期望 DRAM 以准确的理论速率交付(由于各种 低级问题,讨论了一下 here),但你经常可以得到 大约 90% 或更多。

      因此 (1) 的主要结果是您可以将未命中 RAM 视为一种请求响应系统。 DRAM 未命中分配一个填充缓冲区,当请求返回时释放该缓冲区。对于需求未命中,每个 CPU 只有 10 个这样的缓冲区,这对单个 CPU 可以根据其延迟生成的需求内存带宽施加了严格限制

      例如,假设您的E5-2680 对 DRAM 的延迟为 80ns。每个请求都会带来一个 64 字节的高速缓存行,因此您只需将请求串行发送到 DRAM,您会期望吞吐量只有微不足道的 64 bytes / 80 ns = 0.8 GB/s,而您会再次将其减少一半(至少)以获得memcpy图,因为它需要读取写入。幸运的是,您可以使用 10 个行填充缓冲区,这样您就可以将 10 个并发请求重叠到内存中,并将带宽增加 10 倍,从而实现 8 GB/s 的理论带宽。

      如果您想深入了解更多细节,this thread 几乎是纯金。您会发现来自John McCalpin, aka "Dr Bandwidth 的事实和数据将成为下面的共同主题。

      那么让我们进入细节并回答这两个问题......

      为什么 memcpy 比服务器上的 memmove 或手卷副本慢这么多?

      您向您展示了笔记本电脑系统在大约 120 毫秒 内完成 memcpy 基准测试,而服务器部件大约需要 300 毫秒。您还表明,这种缓慢主要不是根本性的,因为您可以使用 memmove 和您的手动卷起的 memcpy(以下称为 hrm)来实现大约 160 毫秒 的时间,很多更接近(但仍慢于)笔记本电脑的性能。

      我们已经在上面展示过,对于单核,带宽受限于总可用并发和延迟,而不是 DRAM 带宽。我们预计服务器部分的延迟可能会更长,但300 / 120 = 2.5x 不会更长!

      答案在于流式(又称非临时)存储。您正在使用的 memcpy 的 libc 版本使用它们,但 memmove 没有。您确认了您的“天真”memcpy 也没有使用它们,以及我将 asmlib 配置为使用流媒体存储(慢)而不是(快)。

      流媒体存储损害了单 CPU 数量,因为:

      • (A) 它们防止预取将要存储的行带入缓存,这允许更多的并发性,因为预取硬件在 10 个填充缓冲区之外还有其他专用缓冲区> 需求加载/存储使用。
      • (B) E5-2680 被称为particularly slow 用于流媒体商店。

      上面链接的帖子中引用 John McCalpin 的话可以更好地解释这两个问题。关于预取有效性和流存储的话题he says

      使用“普通”存储,L2 硬件预取器可以在 提前并减少 Line Fill Buffers 被占用的时间, 从而增加持续带宽。另一方面,与 流式传输(缓存绕过)存储,行填充缓冲区条目 商店被占用了将数据传递到的全部时间 DRAM控制器。在这种情况下,加载可以通过 硬件预取,但商店不能,所以你得到一些加速, 但如果加载和存储都是 加速。

      ...然后对于 E5 上流媒体存储的明显更长的延迟,he says

      至强 E3 更简单的“非核心”可能会导致显着降低 流式存储的行填充缓冲区占用率。至强 E5 有一个 更复杂的环形结构来导航以便移交 将存储从核心缓冲区流式传输到内存控制器,因此 占用率的差异可能比内存(读取)更大 延迟。

      特别是,McCalpin 博士测量到 E5 的速度比具有“客户端”非内核的芯片慢了约 1.8 倍,但 OP 报告的 2.5 倍的速度与此一致,因为 STREAM TRIAD 报告了 1.8 倍的分数,它的负载:商店比率为 2:1,而 memcpy 为 1:1,商店是有问题的部分。

      这并不会使流式传输成为一件坏事 - 实际上,您是在用延迟来换取更小的总带宽消耗。您获得的带宽更少,因为您在使用单个内核时受到并发限制,但您避免了所有读取所有权流量,因此如果您在所有内核上同时运行测试,您可能会看到(小的)好处。

      到目前为止,使用相同 CPU 的其他用户也报告了完全相同的减速,而不是您的软件或硬件配置的产物。

      为什么使用普通商店时服务器部分还是慢?

      即使在纠正了非临时存储问题之后,您仍然看到服务器部分的 160 / 120 = ~1.33x 速度大致下降。什么给了?

      服务器 CPU 在所有方面都更快或至少等于客户端 CPU,这是一个常见的谬误。这不是真的 - 您在服务器部件上支付的费用(通常为 2,000 美元左右)主要是 (a) 更多内核 (b) 更多内存通道 (c) 支持更多总 RAM (d) 支持“企业级”功能,例如 ECC、虚拟化功能等5

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

      • 服务器部件具有更可扩展但复杂的“非核心”,通常需要支持更多核心,因此通往 RAM 的路径更长。
      • 服务器部件支持更多 RAM(100 GB 或几 TB),这通常需要 electrical buffers 来支持如此大的数量。
      • 在 OP 的情况下,服务器部件通常是多插槽的,这为内存路径增加了跨插槽一致性问题。

      因此,服务器部件的延迟通常比客户端部件长 40% 到 60%。对于 E5,您可能会发现 ~80 ns 是 RAM 的typical latency,而客户端部分更接近 50 ns。

      因此,任何受 RAM 延迟限制的东西在服务器部件上的运行速度都会变慢,事实证明,memcpy 在单个内核上 受到延迟限制。这很令人困惑,因为memcpy 似乎 像带宽测量,对吧?如上所述,单个内核没有足够的资源来一次保持足够多的 RAM 请求以接近 RAM 带宽6,因此性能直接取决于延迟。

      另一方面,客户端芯片具有较低的延迟和较低的带宽,因此一个内核更接近于饱和带宽(这通常是为什么流媒体存储在客户端部分是一个巨大的胜利 - 即使是单个内核可以接近 RAM 带宽,流存储提供的 50% 存储带宽减少有很大帮助。

      参考文献

      有很多很好的资源可以阅读更多关于这些东西的信息,这里有几个。


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

      2特别是行填充缓冲区的数量有apparently been constant at 10几代,包括这个问题中提到的架构。

      3 当我们在这里说需求时,我们的意思是它与代码中的显式加载/存储相关联,而不是说由预取引入。

      4 当我在这里提到 server 部分时,我指的是具有 server uncore 的 CPU。这主要是指E5系列,因为E3系列一般uses the client uncore

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

      6 对于little's law,延迟为 80 ns,我们需要 (51.2 B/ns * 80 ns) == 4096 bytes 或 64 条高速缓存线以达到最大带宽,但一个内核提供的带宽少于20.

      【讨论】:

        【解决方案7】:

        服务器 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 来确认。

        您应该将-march=native-O3 添加到您的CFLAGSCXXFLAGS。您可能会看到性能的显着提升。它应该使用 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。另请参阅How to tell GCC that a pointer argument is always double-word-aligned?等问题

        由于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 时没有使用原生 CPU 扩展。例如,请参阅Ubuntu Issue 1616723, Clang 3.4 only advertises SSE2Ubuntu Issue 1616723, Clang 3.5 only advertises SSE2Ubuntu Issue 1616723, Clang 3.6 only advertises SSE2

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2013-01-07
          • 1970-01-01
          • 1970-01-01
          • 2013-12-24
          相关资源
          最近更新 更多