为什么memcpy()和memmove()比指针增量快?


92

我复制从N个字节pSrcpDest。这可以在一个循环中完成:

for (int i = 0; i < N; i++)
    *pDest++ = *pSrc++

为什么这比memcpyor 慢memmove?他们使用什么技巧来加快速度?


2
您的循环仅复制一个位置。我认为您某种程度上意味着要增加指针。
Mysticial 2011年

13
或者,您可以像我一样为他们修复它。而且,顺便说一句,没有真正的C程序员能够1一直计数N,它总是0N-1:-)
paxdiablo 2011年

6
@paxdiablo:如果要遍历数组,请确定。但是在很多情况下,从1到N的循环就很好了。取决于您对数据的处理方式-例如,如果要向用户显示从1开始的编号列表,则从1开始可能更有意义。在任何情况下,intsize_t应使用无符号类型时,它都会忽略用作计数器的更大问题。
比利·奥尼尔

2
@paxdiablo您也可以从N到1计数。在某些处理器上,它会消除一条比较指令,因为减量将在分支指令达到零时为其设置适当的位。
2011年

6
我认为问题的前提是错误的。现代编译器会将其转换为memcpymemmove(取决于它们是否可以判断指针是否可能是别名)。
大卫·史瓦兹

Answers:


120

因为memcpy使用字指针而不是字节指针,所以memcpy实现也经常用SIMD指令编写,这使得可以一次重排128位。

SIMD指令是汇编指令,可以对最长16个字节的向量中的每个元素执行相同的操作。其中包括加载和存储指令。


15
当您打开GCC最多-O3,它将使用SIMD的循环,至少如果它知道pDestpSrc不别名。
Dietrich Epp

我目前正在使用64字节(512位)SIMD的Xeon Phi进行工作,因此“最多16字节”的内容使我微笑。另外,您必须指定要启用SIMD的目标CPU,例如-march = native。
yakoudbz

也许我应该修改答案。:)
onemasse '16

即使在发布时,这也已经过时了。x86(2011年发货)上的AVX向量长32个字节,AVX-512长64个字节。有些架构具有1024位或2048位向量,甚至具有可变的向量宽度,例如ARM SVE
phuclv

@phuclv可能会提供说明,那么您是否有任何证据证明memcpy使用了它们?图书馆通常需要一段时间才能赶上,我可以找到的最新图书馆使用SSSE3,并且比2011
。– Pete Kirkham

81

内存复制例程比通过指针的简单内存复制要复杂得多,而且速度更快:

void simple_memory_copy(void* dst, void* src, unsigned int bytes)
{
  unsigned char* b_dst = (unsigned char*)dst;
  unsigned char* b_src = (unsigned char*)src;
  for (int i = 0; i < bytes; ++i)
    *b_dst++ = *b_src++;
}

改进之处

可以做的第一个改进是将一个指针对齐到字边界上(按字我的意思是本机整数大小,通常为32位/ 4字节,但在较新的体系结构上可以为64位/ 8字节),并使用字大小的移动/复制说明。这需要使用字节到字节的复制,直到指针对齐为止。

void aligned_memory_copy(void* dst, void* src, unsigned int bytes)
{
  unsigned char* b_dst = (unsigned char*)dst;
  unsigned char* b_src = (unsigned char*)src;

  // Copy bytes to align source pointer
  while ((b_src & 0x3) != 0)
  {
    *b_dst++ = *b_src++;
    bytes--;
  }

  unsigned int* w_dst = (unsigned int*)b_dst;
  unsigned int* w_src = (unsigned int*)b_src;
  while (bytes >= 4)
  {
    *w_dst++ = *w_src++;
    bytes -= 4;
  }

  // Copy trailing bytes
  if (bytes > 0)
  {
    b_dst = (unsigned char*)w_dst;
    b_src = (unsigned char*)w_src;
    while (bytes > 0)
    {
      *b_dst++ = *b_src++;
      bytes--;
    }
  }
}

根据源指针或目标指针是否正确对齐,不同的体系结构将执行不同的操作。例如,在XScale处理器上,通过对齐目标指针而不是源指针,我获得了更好的性能。

为了进一步提高性能,可以执行一些循环展开操作,以便将更多数据加载到处理器的寄存器中,这意味着可以对加载/存储指令进行交织,并通过其他指令(例如循环计数等)隐藏其延迟。由于加载/存储指令的等待时间可能完全不同,因此处理器带来的好处差别很大。

在此阶段,由于需要手动放置加载和存储指令以获得延迟隐藏和吞吐量的最大好处,因此代码最终以汇编语言而不是C(或C ++)语言编写。

通常,应在展开循环的一次迭代中复制整个缓存行数据。

这带来了我的下一个改进,增加了预取。这些是特殊的指令,它们告诉处理器的缓存系统将内存的特定部分加载到其缓存中。由于在发出指令与填充高速缓存行之间存在延迟,因此需要以这样的方式放置指令,以便在复制数据时就可以使用该数据,并且不再早/晚。

这意味着将预取指令放在函数的开头以及主复制循环中。通过复制循环中间的预取指令,可以获取将在多个迭代时间内复制的数据。

我不记得了,但是预取目标地址和源地址也可能会有所帮助。

因素

影响可以快速复制内存的主要因素有:

  • 处理器,其缓存和主内存之间的等待时间。
  • 处理器的缓存行的大小和结构。
  • 处理器的内存移动/复制指令(等待时间,吞吐量,寄存器大小等)。

因此,如果您想编写一个高效,快速的内存处理例程,则需要了解很多有关要编写的处理器和体系结构的信息。可以说,除非您在某个嵌入式平台上进行编写,否则仅使用内置的内存复制例程会容易得多。


现代CPU将检测线性内存访问模式并自行开始预取。因此,我希望预取指令不会有太大的不同。
MAXY

@maxy在我已经实现了内存复制例程的少数体系结构上,添加预取已显着地帮助了。虽然当前英特尔/ AMD芯片确实可以提前预取足够的信息,但仍有许多较旧的芯片和其他体系结构没有做到这一点。

谁能解释“(b_src&0x3)!= 0”?我不明白,而且-它不会编译(引发错误:对二进制&无效的运算符&:unsigned char和int);
David Refaeli

“(b_src&0x3)!= 0”正在检查最低2位是否不为0。因此,源指针是否与4字节的倍数对齐。发生编译错误是因为它将0x3视为一个字节而不是in,您可以通过使用0x00000003或0x3i来解决此问题(我认为)。

b_src & 0x3无法编译,因为不允许对指针类型进行按位算术运算。你必须将它转换为(u)intptr_t第一
phuclv

18

memcpy可以一次复制多个字节,具体取决于计算机的体系结构。大多数现代计算机可以在单个处理器指令中使用32位或更多位。

一个示例实现中

    00026 *为了快速进行复制,请优化两个指针均处于常见状态
    00027 *和长度按字对齐,然后一次复制一个字
    一次字节00028 *。否则,按字节复制。

8
在没有板载缓存的386(例如)上,这确实产生了很大的不同。在大多数现代处理器上,读和写将一次只发生在一条高速缓存行中,并且到内存的总线通常会成为瓶颈,因此期望将性能提高百分之几,而不是四倍。
杰里·科芬

2
我认为当您说“从源头”时,您应该更加明确。当然,这是某些体系结构上的“源”,但是肯定不是在BSD或Windows计算机上。(而且,即使在GNU系统之间,此功能通常也有很多不同)
Billy ONeal

@Billy ONeal:绝对正确+1 ...有多种方法可以给猫皮剥皮。那只是一个例子。固定!感谢您的建设性评论。
Mark Byers

7

您可以memcpy()使用以下任何一种技术来实现,其中一些技术取决于您的体系结构以提高性能,并且它们都比您的代码快得多:

  1. 使用较大的单位,例如32位字而不是字节。您也可以在这里(或可能必须)处理对齐问题。例如,在某些平台上,您无法将32位字读/写到奇数个内存位置,而在其他平台上,则要付出巨大的性能损失。要解决此问题,地址必须是4的整数倍。对于64位CPU,您可以将此地址最多扩展为64位,或者使用SIMD(单指令,多数据)指令(MMXSSE等)将其提高到64位。

  2. 您可以使用编译器可能无法从C优化的特殊CPU指令。例如,在80386上,可以使用“ rep”前缀指令+“ movsb”指令来移动N字节,方法是将N放入计数寄存器。好的编译器会为您完成此任务,但是您可能会缺少一个好的编译器。请注意,该示例往往不能很好地证明速度,但是与对齐+较大的单元指令结合使用时,它可能比某些CPU上的大多数其他命令都要快。

  3. 循环展开 -在某些CPU上分支可能非常昂贵,因此展开循环可以减少分支的数量。这也是与SIMD指令和超大型单元结合使用的好技术。

例如,httpmemcpy://www.agner.org/optimize/#asmlib 的实现能击败大多数(很少)。如果您阅读了源代码,它将包含大量的内联汇编代码,这些内联汇编代码可以提取上述三种技术,并根据您所运行的CPU选择哪些技术。

注意,也可以进行类似的优化来查找缓冲区中的字节。strchr()和朋友之间的交往往往比您手摇的同等速度快。.NETJava尤其如此。例如,在.NET中,内置程序String.IndexOf()甚至比Boyer-Moore字符串搜索要快得多,因为它使用了上述优化技术。


1
您链接到的同一个Agner Fog也理论上认为,在现代CPU上展开循环会适得其反

如今,大多数CPU都具有良好的分支预测能力,这在典型情况下应该抵消循环展开的好处。一个好的优化编译器有时仍可以使用它。
thomasrutter

5

简短答案:

  • 缓存填充
  • 在可能的情况下使用字大小转移而不是字节转移
  • SIMD魔术

4

我不知道它是否真正用于的任何实际实现中memcpy,但我认为Duff的设备在这里值得一提。

维基百科

send(to, from, count)
register short *to, *from;
register count;
{
        register n = (count + 7) / 8;
        switch(count % 8) {
        case 0:      do {     *to = *from++;
        case 7:              *to = *from++;
        case 6:              *to = *from++;
        case 5:              *to = *from++;
        case 4:              *to = *from++;
        case 3:              *to = *from++;
        case 2:              *to = *from++;
        case 1:              *to = *from++;
                } while(--n > 0);
        }
}

请注意,上述内容不是memcpy因为它故意不增加to指针。它执行的操作略有不同:将其写入内存映射寄存器。有关详细信息,请参见Wikipedia文章。


Duff的设备,或者只是初始跳转机制,是复制前1..3(或1..7)字节的好方法,以便将指针对齐到更好的边界,在此边界处可以使用更大的内存移动指令。

@MarkByers:该代码说明了一个稍有不同的操作(*to指的是内存映射寄存器,并且故意不增加-请参阅链接的文章)。正如我认为我已经明确指出的那样,我的回答并不是试图提供一种有效的方法memcpy,它只是提到了一种相当好奇的技术。
NPE

@Daemin同意,如您所说,您可以跳过do {} while(),并且编译器会将开关转换为跳转表。当您要处理其余数据时非常有用。应该提到有关Duff设备的警告,显然是在较新的体系结构(较新的x86)上,分支预测是如此有效,以至于Duff的设备实际上比简单循环慢。
2011年

1
哦,不。不是Duff的装置。请不要使用Duff的设备。请。使用PGO,让我的编译器为您做循环展开。
Billy ONeal

不,Duff的设备绝对不会在任何现代实现中使用。
gnasher729

3

像其他人一样,memcpy复制大于1字节的块。以单词大小的块进行复制要快得多。但是,大多数实现会更进一步,并在循环之前运行多条MOV(字)指令。例如,每个循环复制8个字块的好处是循环本身很昂贵。该技术将条件分支的数量减少了8倍,优化了巨型块的副本。


1
我不认为这是真的。您可以展开循环,但不能在单个指令中复制比一次在目标体系结构上一次寻址更多的数据。另外,也有展开循环的开销……
Billy ONeal

@Billy ONeal:我不认为这就是VoidStar的意思。通过具有多个连续的移动指令,减少了计算单位数量的开销。
wallyk 2011年

@Billy ONeal:您错过了重点。一次1字就像MOV,JMP,MOV,JMP等。在哪里可以做MOV MOV MOV MOV JMP。我之前写过孟菲斯文章,也已经做了很多基准测试;)
VoidStar 2011年

@wallyk:也许吧。但是他说“复制更大的块”-这实际上是不可能的。如果他的意思是循环展开,那么他应该说“大多数实现将其进一步展开并展开循环”。书面答案最好是误导性的,最坏情况是错误的。
比利·奥尼尔

@VoidStar:同意---现在更好了。+1。
比利·奥尼尔

2

答案很好,但是如果您仍然想memcpy自己实现斋戒,那么有一篇有趣的博客文章关于斋戒,C中的斋戒

void *memcpy(void* dest, const void* src, size_t count)
{
    char* dst8 = (char*)dest;
    char* src8 = (char*)src;

    if (count & 1) {
        dst8[0] = src8[0];
        dst8 += 1;
        src8 += 1;
    }

    count /= 2;
    while (count--) {
        dst8[0] = src8[0];
        dst8[1] = src8[1];

        dst8 += 2;
        src8 += 2;
    }
    return dest;
}

甚至,优化内存访问也会更好。


1

因为像许多库例程一样,它已针对正在运行的体系结构进行了优化。其他人已经发布了可以使用的各种技术。

如果有选择,请使用库例程,而不要自己动手。这是DRY的一种变体,我称之为DRO(请勿重复其他内容)。而且,与您自己的实现相比,库例程不太可能出错。

我已经看到内存访问检查器抱怨内存或字符串缓冲区的读取超出范围,这不是字长的倍数。这是使用优化的结果。


0

您可以查看memset,memcpy和memmove的MacOS实现。

在启动时,操作系统确定运行的处理器。它为每个受支持的处理器内置了经过专门优化的代码,并且在启动时将jmp指令存储到正确的代码中固定的只读/只读位置。

C memset,memcpy和memmove实现只是跳转到该固定位置。

这些实现使用不同的代码,具体取决于memcpy和memmove的源和目标的对齐方式。他们显然使用了所有可用的向量功能。当您复制大量数据时,它们还使用非缓存变量,并具有减少页表等待的说明。这不仅仅是汇编代码,它是由对每种处理器体系结构非常了解的人编写的汇编代码。

英特尔还增加了汇编程序指令,可以使字符串操作更快。例如,使用一条支持strstr的指令在一个周期内执行256字节比较。


苹果的memset / memcpy / memmove开源版本只是一个通用版本,它将比使用SIMD的真实版本要慢得多
phuclv
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.