74

我们最近购买了一些新服务器,但 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 选项

根据评论,我尝试使用不同的 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 )

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

笔记本电脑与服务器 memcpy 性能

请注意,非常大的差异是较大缓冲区大小的性能。最后测试的大小 (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

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

4

7 回答 7

25

[我会发表评论,但没有足够的声誉这样做。]

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

  • 如果你反转你的天真方向memcpy(即转换为*p_dest-- = *p_src--),那么你可能会得到比正向更差的性能(对我来说约为 637 毫秒)。glibc 2.12中的一个变化暴露了调用重叠缓冲区memcpy()的几个错误( http://lwn.net/Articles/414467/),我相信这个问题是由于切换到一个向后操作的版本引起的。因此,后向与前向副本可以解释/差异。memcpymemcpymemcpy()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 单插座板上,我看到 9.6 GB/s memcpy(104ms)。Haswell 系统上的 RAM 为 DDR3-1600(与其他系统相同)。

更新

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

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

管理具有两个 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 更快。

于 2014-04-02T16:29:23.883 回答
8

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

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

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

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

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

于 2014-04-02T17:28:03.900 回答
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 库代码太聪明了。

于 2014-04-02T15:43:22.470 回答
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;
}
于 2015-05-21T23:51:09.377 回答
3

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

不过,首先,我们需要一个心智模型,了解大型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 以准确的理论速率交付是不切实际的(由于各种低级问题,在 稍作讨论),但您通常可以达到 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 的理论带宽。

如果你想深入了解更多细节,这个线程几乎是纯金。你会发现来自John McCalpin 的事实和数据,又名“带宽博士”将是下面的一个共同主题。

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

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

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

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

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

流式存储损害了单个 CPU数量,因为:

  • (A)它们阻止预取将要存储的行带入缓存,这允许更多的并发性,因为预取硬件具有超出需要加载/存储使用的 10 个填充缓冲区的其他专用缓冲区。
  • (B)众所周知,E5-2680对于流媒体商店来说特别慢。

上述链接线程中引用 John McCalpin 的话可以更好地解释这两个问题。关于预取有效性和流媒体存储的话题,他说

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

...然后对于 E5 上流媒体商店的明显更长的延迟,他说

Xeon E3 更简单的“非核心”可以显着降低流媒体商店的 Line Fill Buffer 占用率。Xeon E5 有一个更复杂的环形结构来导航,以便将流存储从核心缓冲区移交给内存控制器,因此占用率的差异可能比内存(读取)延迟更大。

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

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

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

为什么使用普通商店时服务器部分仍然较慢?

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

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

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

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

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

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

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

参考

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


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

2特别是,行填充缓冲区的数量显然在几代人中一直保持在 10,包括这个问题中提到的架构。

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

4当我在这里提到服务器部分时,我指的是带有服务器 uncore的 CPU 。这主要是指 E5 系列,因为 E3 系列一般使用客户端 uncore

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

6根据 80 ns 延迟的Per little 定律,我们需要(51.2 B/ns * 80 ns) == 4096 bytes始终运行 64 条高速缓存线才能达到最大带宽,但一个内核提供的带宽少于 20 条。

于 2017-01-31T17:23:01.407 回答
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

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.

还有一个建议是使用堆。在典型的现代系统上,它总是 16 字节对齐。当涉及来自堆的指针时,GCC 应该认识到它可以卸载到 MMX 单元(没有潜在问题void*malloc问题)。

最后,有一段时间,Clang 在使用-march=native. 例如,请参见Ubuntu 问题 1616723,Clang 3.4 仅广告 SSE2Ubuntu 问题 1616723,Clang 3.5 仅广告 SSE2,以及Ubuntu 问题 1616723,Clang 3.6 仅广告 SSE2

于 2017-03-27T07:06:01.930 回答