比memcpy更快的替代方案?

77

我有一个函数在执行memcpy操作,但是它占用了大量的CPU周期。有没有比使用memcpy更快的替代方法或者其他方式来移动内存块?


通常最好不要复制任何东西,这样会更快。我不知道你是否能够调整你的函数以避免复制,但值得研究一下。 - High Performance Mark
1
简短回答:也许是可能的。提供更多细节,如架构、平台等。在嵌入式世界中,很有可能重新编写一些libc函数,因为它们的性能不佳。 - INS
交换指针是一个选项吗? - huseyin tugrul buyukisik
16个回答

161

memcpy可以说是在内存中复制字节的最快方法。如果你需要更快的方式 - 尝试找到一种方法来实现复制数据,例如仅交换指针,而非数据本身。


3
我们最近遇到了一个问题,当处理某个文件时,我们的代码突然变得非常缓慢并且消耗了大量额外的内存。原来这个文件有一个巨大的元数据块,而其他文件没有元数据或者只有小块的元数据。这些元数据被复制了多次,造成了时间和内存的浪费。将复制操作替换为传递常量引用。 - sharptooth
22
关于更快的memcpy是一个很好的问题,但这个答案提供了一种解决方法,而不是一个答案。例如,http://software.intel.com/en-us/articles/memcpy-performance/ 解释了一些非常严重的原因,为什么memcpy通常比它本应该更有效率。 - DS.
13
之前指向英特尔关于memcpy的帖子的链接似乎不再公开,但该文章可以在这里(http://web.archive.org/web/20131223174037/http://software.intel.com/en-us/articles/memcpy-performance/)和这里(http://codepen.io/anon/pen/WvQyRd?editors=100)找到。 - DS.
2
这段代码即使在今天也不能算正确。memcpy通常是比较朴素的方法——虽然不是将内存复制到其他地方最慢的方式,但通常很容易通过一些循环展开来击败它,甚至你可以使用汇编语言进一步优化。 - jheriko
2
这个回答并没有回答问题。问题是一个有效的问题。我会要求 Stack Overflow 移除“已回答”的标志。 - iamacomputer
显示剩余5条评论

59

这是针对带有AVX2指令集的x86_64的答案。尽管类似的方法可能适用于带有SIMD的ARM / AArch64。

在Ryzen 1800X上,如果单个内存通道完全填充(2个插槽,每个插槽16 GB DDR4),则以下代码在MSVC++2017编译器上比 memcpy()快1.56倍。如果您使用2个DDR4模块填充了两个内存通道,即您使用了所有4个DDR4插槽,则可以获得进一步的2倍快速内存复制。对于三通道-(四通道)内存系统,如果将代码扩展为类似的AVX512代码,则可以获得进一步的1.5(2.0)倍快速内存复制。对于仅支持AVX2的三/四通道系统,如果所有插槽都忙碌,则不会更快,因为要完全加载它们需要同时加载/存储超过32字节(三通道48字节,四通道64字节),而AVX2一次最多只能加载/存储32字节。但是,在某些系统上,多线程可以在没有AVX512或甚至AVX2的情况下缓解此问题。

因此,以下是复制代码,假定您正在复制大小是32的倍数且块大小为32字节对齐的大块内存。

对于非倍数大小和非对齐块,可以编写引言/结论代码,将宽度减少到16(SSE4.1),8、4、2,最后一次为块头和尾部每次1个字节。还可以在中间使用2-3个__m256i值的本地数组,作为从源读取对齐数据和向目标写入对齐数据之间的代理。

#include <immintrin.h>
#include <cstdint>
/* ... */
void fastMemcpy(void *pvDest, void *pvSrc, size_t nBytes) {
  assert(nBytes % 32 == 0);
  assert((intptr_t(pvDest) & 31) == 0);
  assert((intptr_t(pvSrc) & 31) == 0);
  const __m256i *pSrc = reinterpret_cast<const __m256i*>(pvSrc);
  __m256i *pDest = reinterpret_cast<__m256i*>(pvDest);
  int64_t nVects = nBytes / sizeof(*pSrc);
  for (; nVects > 0; nVects--, pSrc++, pDest++) {
    const __m256i loaded = _mm256_stream_load_si256(pSrc);
    _mm256_stream_si256(pDest, loaded);
  }
  _mm_sfence();
}

这段代码的一个重要特点是在复制时跳过CPU缓存:如果涉及CPU缓存(即使用AVX指令且没有_stream_),在我的系统上,复制速度会下降数倍。

我的DDR4内存是2.6GHz CL13。因此,当从一个数组复制8GB数据到另一个数组时,我得到了以下速度:

memcpy(): 17,208,004,271 bytes/sec.
Stream copy: 26,842,874,528 bytes/sec.

请注意,在这些测量中,输入和输出缓冲区的总大小是按经过的秒数分配的。因为对于数组的每个字节,有两个内存访问:一个是从输入数组读取字节,另一个是将字节写入输出数组。换句话说,当从一个数组复制8GB到另一个数组时,您执行了16GB的内存访问操作。

适度的多线程可以进一步提高性能约1.44倍,因此在我的机器上,相对于memcpy(),总增加量达到2.55倍。以下是流复制性能如何取决于在我的机器上使用的线程数:

Stream copy 1 threads: 27114820909.821 bytes/sec
Stream copy 2 threads: 37093291383.193 bytes/sec
Stream copy 3 threads: 39133652655.437 bytes/sec
Stream copy 4 threads: 39087442742.603 bytes/sec
Stream copy 5 threads: 39184708231.360 bytes/sec
Stream copy 6 threads: 38294071248.022 bytes/sec
Stream copy 7 threads: 38015877356.925 bytes/sec
Stream copy 8 threads: 38049387471.070 bytes/sec
Stream copy 9 threads: 38044753158.979 bytes/sec
Stream copy 10 threads: 37261031309.915 bytes/sec
Stream copy 11 threads: 35868511432.914 bytes/sec
Stream copy 12 threads: 36124795895.452 bytes/sec
Stream copy 13 threads: 36321153287.851 bytes/sec
Stream copy 14 threads: 36211294266.431 bytes/sec
Stream copy 15 threads: 35032645421.251 bytes/sec
Stream copy 16 threads: 33590712593.876 bytes/sec

代码如下:

void AsyncStreamCopy(__m256i *pDest, const __m256i *pSrc, int64_t nVects) {
  for (; nVects > 0; nVects--, pSrc++, pDest++) {
    const __m256i loaded = _mm256_stream_load_si256(pSrc);
    _mm256_stream_si256(pDest, loaded);
  }
}

void BenchmarkMultithreadStreamCopy(double *gpdOutput, const double *gpdInput, const int64_t cnDoubles) {
  assert((cnDoubles * sizeof(double)) % sizeof(__m256i) == 0);
  const uint32_t maxThreads = std::thread::hardware_concurrency();
  std::vector<std::thread> thrs;
  thrs.reserve(maxThreads + 1);

  const __m256i *pSrc = reinterpret_cast<const __m256i*>(gpdInput);
  __m256i *pDest = reinterpret_cast<__m256i*>(gpdOutput);
  const int64_t nVects = cnDoubles * sizeof(*gpdInput) / sizeof(*pSrc);

  for (uint32_t nThreads = 1; nThreads <= maxThreads; nThreads++) {
    auto start = std::chrono::high_resolution_clock::now();
    lldiv_t perWorker = div((long long)nVects, (long long)nThreads);
    int64_t nextStart = 0;
    for (uint32_t i = 0; i < nThreads; i++) {
      const int64_t curStart = nextStart;
      nextStart += perWorker.quot;
      if ((long long)i < perWorker.rem) {
        nextStart++;
      }
      thrs.emplace_back(AsyncStreamCopy, pDest + curStart, pSrc+curStart, nextStart-curStart);
    }
    for (uint32_t i = 0; i < nThreads; i++) {
      thrs[i].join();
    }
    _mm_sfence();
    auto elapsed = std::chrono::high_resolution_clock::now() - start;
    double nSec = 1e-6 * std::chrono::duration_cast<std::chrono::microseconds>(elapsed).count();
    printf("Stream copy %d threads: %.3lf bytes/sec\n", (int)nThreads, cnDoubles * 2 * sizeof(double) / nSec);

    thrs.clear();
  }
}

更新于2023-01-18: 我不再拥有那个系统,但是 2666MHz DDR4 标记为 PC4-21300U,意味着每个 RAM 插槽传输速率为 22334668800 字节/秒。由于我有 2 个 RAM 插槽,因此最大带宽为 44669337600 字节/秒。通过使用 SIMD 和多线程的方法,当使用 5 个线程时,实现了理论带宽的 87.72%


_mm256_stream_load_si256 只有在复制 WC 存储区域(例如从视频 RAM)时才会执行特殊操作。否则,它只是一个更慢的(1 个额外的 uop)对你分配的正常内存的 vmovdqa(这将是 WB = 写回高速缓存、强序的,并且 movntdqa loads,不像 NT 存储,不会覆盖强序)。你不能绕过缓存读取正常内存,只能有时通过 NT 预取最小化污染。(但这很难调整,取决于机器,而不仅仅是代码。) - Peter Cordes
1
Enhanced REP MOVSB for memcpy 中详细介绍了为什么在需要大量复制时,NT 存储(或 ERMSB CPU 上的 rep movsb)会是一个优势。对于小到中等规模的复制,如果您很快就要再次读取内存,则绕过缓存是一个很大的缺点。 - Peter Cordes
2
一个好的memcpy(例如GNU / Linux上的glibc)将在超过某个大小阈值时使用NT存储,或者在某些CPU上简单地使用rep movsb。如果您的C实现的memcpy尚未这样做,或者您知道此复制应该是非暂态的,则手动执行它可能是有意义的。 - Peter Cordes
1
如果您的2根内存条已正确安装,每个通道上有一个DIMM,则已经使用双通道。再加一对DIMM也不会使其更快。 - Peter Cordes
您配置的系统的理论最大内存带宽(GB/s)是多少? - sham1810
显示剩余2条评论

13
请提供更多细节。在i386架构上,memcpy很可能是最快的复制方式。但在不具有优化版本的不同架构上,最好重写memcpy函数。我在使用汇编语言的自定义ARM架构上进行了此操作。如果您传输大块内存,则DMA可能是您要寻找的答案。
请提供更多细节-架构,操作系统(如果相关)。

3
对于ARM而言,现在的libc实现速度比你自己创造的要快。对于小型复制(任何小于一页的内容),在函数内部使用汇编循环可能更快。但是,对于大型复制,你将无法击败libc实现,因为不同的处理器有稍微不同的“最优”代码路径。例如,Cortex8最适合使用NEON复制指令,而Cortex9则更快使用ldm/stm ARM指令。你不能编写一段适用于这两个处理器都快速的代码,但你可以为大型缓冲区调用memcpy。 - MoDJ
@MoDJ: 我希望标准的 C 库能够包含几个不同的 memcpy 变体,这些变体在所有情况下都产生定义良好的行为,但在优化方面有所不同,在某些情况下还会对齐和未对齐用法进行限制。如果代码通常需要复制少量字节或已知为对齐字,则一个天真的逐字符实现可以在比一些更高级的 memcpy() 实现需要决定行动方案的时间更短的时间内完成工作。 - supercat

7
通常编译器附带的标准库会以目标平台最快的方式实现memcpy()

6
实际上,memcpy并不是最快捷的方式,特别是在您多次调用它时。我也有一些需要加速的代码,但是由于memcpy具有太多不必要的检查,因此速度非常慢。例如,它会检查目标和源内存块是否重叠,以及是否应该从块的后面而不是前面开始复制。如果您不关心这些考虑因素,那么肯定可以做得更好。我有一些代码,但以下是可能更好的版本:Very fast memcpy for image processing?
如果您搜索,还可以找到其他实现方式。但是要真正提高速度,您需要使用汇编版本。

我尝试使用 SSE2 编写了类似的代码。结果发现在我的 AMD 系统上比内置函数慢了 4 倍。如果可以避免,最好不要复制别人的代码。 - hookenz
2
尽管memmove必须检查和处理重叠,但不要求memcpy这样做。更大的问题是,为了在复制大块时效率高,memcpy的实现需要在开始工作之前选择复制方法。如果代码需要能够复制任意数量的字节,但那个数字90%的时间是1,9%的时间是2,0.9%的时间是3等等,并且在之后不需要countdestsrc的值,则内联的if (count) do *dest+=*src; while(--count > 0);可能比“更聪明”的例程更好。 - supercat
1
顺便提一下,在某些嵌入式系统上,memcpy 不是最快的方法的另一个原因是 DMA 控制器有时可以比 CPU 更少地复制一块内存,但最有效的复制方式可能是启动 DMA,然后在 DMA 运行时进行其他处理。在具有单独的前端代码和数据总线的系统上,可以配置 DMA,使其在 CPU 不需要数据总线用于其他任何事情时在每个周期复制数据。这可能比使用 CPU 进行复制使用更好的性能... - supercat
1
...start_memcpy()await_memcpy_complete()函数,但任何代码通常都必须根据特定的应用要求进行定制,标准库中没有类似的内容。 - supercat

4
这是一个备选的C版本memcpy,可以内联使用,并且在我用它进行的应用程序中,我发现它比GCC for Arm64的memcpy快约50%。它是独立于64位平台的。如果使用情况不需要尾部处理,则可以删除它以获得更快的速度。复制uint32_t数组,较小的数据类型未经测试但可能有效。可能可以适应其他数据类型。64位复制(同时复制两个索引)。32位也应该可以工作,但速度较慢。感谢Neoscrypt项目。
    static inline void newmemcpy(void *__restrict__ dstp, 
                  void *__restrict__ srcp, uint len)
        {
            ulong *dst = (ulong *) dstp;
            ulong *src = (ulong *) srcp;
            uint i, tail;

            for(i = 0; i < (len / sizeof(ulong)); i++)
                *dst++ = *src++;
            /*
              Remove below if your application does not need it.
              If console application, you can uncomment the printf to test
              whether tail processing is being used.
            */
            tail = len & (sizeof(ulong) - 1);
            if(tail) {
                //printf("tailused\n");
                uchar *dstb = (uchar *) dstp;
                uchar *srcb = (uchar *) srcp;

                for(i = len - tail; i < len; i++)
                    dstb[i] = srcb[i];
            }
        }

1
在 M1 Mac 上,这会变慢。 - ellipticaldoor
2
"*dst++ = *src++;" 这行代码存在内存保护开销。系统中的块实现只需要检查边界一次。 - Hogdotmac
令人惊讶的是,在MSVC中,这将生成对std::memcpy的调用。 - Violet Giraffe

3
有时候像memcpy、memset等函数会以两种不同的方式实现:
  • 一次作为真正的函数
  • 一次作为立即内联的汇编代码
并非所有编译器都默认采用内联汇编版本,您的编译器可能默认使用函数变体,因此会因为函数调用而产生一些开销。 检查您的编译器,了解如何使用函数的内在变体(命令行选项、pragma等)。
编辑:请参见http://msdn.microsoft.com/en-us/library/tzkfha43%28VS.80%29.aspx,了解Microsoft C编译器上内在函数的解释。

3
你应该检查为你的代码生成的汇编代码。你不想让memcpy调用标准库中的memcpy函数 - 你想要的是重复调用最佳的ASM指令来复制最大数量的数据 - 比如rep movsq
如何实现这一点呢?编译器通过将简单的mov替换为调用memcpy来优化对memcpy的调用,只要它知道应该复制多少数据。如果你写一个具有明确定义(constexpr)值的memcpy,你就可以看到这一点。如果编译器不知道这个值,它将不得不退回到memcpy的字节级实现,问题在于memcpy必须遵守一个字节的粒度。它仍然会每次移动128位,但在每个128b之后,它必须检查是否有足够的数据作为128b或者它必须退回到64位、32位和8位(我认为16位可能不是最优的,但我不确定)。
所以,你要么告诉memcpy你的数据大小是由编译器可以优化的常量表达式确定的。这样就不会执行任何对memcpy的调用。你不想传递一个只在运行时才知道的变量给memcpy。这将转化为一个函数调用和大量测试,以检查最佳的复制指令。有时,一个简单的for循环比memcpy更好(消除一个函数调用)。而且,你真的真的不想传递一个奇数字节数来复制给memcpy

2
请查阅您的编译器/平台手册。对于一些微处理器和DSP套件,使用memcpy比使用内置函数或DMA操作慢得多。

2

如果您的平台支持,可以考虑使用mmap()系统调用将数据留在文件中...通常操作系统可以更好地管理它。正如大家一直在说的那样,尽可能避免复制;在这种情况下指针是您的朋友。


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