【问题标题】:Why are memcpy() and memmove() faster than pointer increments?为什么 memcpy() 和 memmove() 比指针增量快?
【发布时间】:2011-12-08 05:34:01
【问题描述】:

我正在将 N 个字节从 pSrc 复制到 pDest。这可以在一个循环中完成:

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

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

【问题讨论】:

  • 您的循环只复制一个位置。我认为您以某种方式打算增加指针。
  • 或者,您可以像我一样为他们修复它。而且,顺便说一句,没有真正的 C 程序员曾经1 计数到N,它总是0N-1 :-)
  • @paxdiablo:如果你在循环数组,当然可以。但是在很多情况下,从 1 循环到 N 就可以了。取决于您对数据所做的工作——例如,如果您向用户显示从 1 开始的编号列表,那么从 1 开始可能更有意义。无论如何,它忽略了使用int 作为计数器的更大问题,而应该使用像size_t 这样的无符号类型。
  • @paxdiablo 您也可以从 N 数到 1。在某些将消除一条比较指令的处理器上,因为减量会在分支指令达到零时设置适当的位。
  • 我认为问题的前提是错误的。现代编译器会将其转换为memcpymemmove(取决于它们是否能够判断指针是否可能别名)。

标签: c++ c loops


【解决方案1】:

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

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

【讨论】:

  • 当您将 GCC 设置为 -O3 时,它将使用 SIMD 进行循环,至少如果它知道 pDestpSrc 不使用别名。
  • 我目前正在开发具有 64 字节(512 位)SIMD 的 Xeon Phi,所以“最多 16 字节”的东西让我微笑。此外,您必须指定要启用 SIMD 的 CPU,例如使用 -march=native。
  • 也许我应该修改我的答案。 :)
  • 即使在发布时也已经过时了。 x86 上的 AVX 向量(2011 年发布)的长度为 32 字节,而 AVX-512 的长度为 64 字节。有些架构具有 1024 位或 2048 位向量,甚至是可变向量宽度,如 ARM SVE
  • @phuclv 虽然当时可能已经提供了说明,但您有任何证据表明 memcpy 使用了它们吗?图书馆通常需要一段时间才能赶上,我能找到的最新的使用 SSSE3,并且比 2011 年更新得多。
【解决方案2】:

内存复制例程可以比通过指针进行的简单内存复制更复杂和更快,例如:

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 在我实现了内存复制例程的少数架构上,添加预取有显着的帮助。虽然当前一代的 Intel/AMD 芯片确实可以提前足够多地进行预取,但也有很多较旧的芯片和其他架构没有。
  • 谁能解释一下“(b_src & 0x3) != 0”?我无法理解,而且 - 它不会编译(抛出错误:二进制 & 的无效运算符:无符号字符和整数);
  • "(b_src & 0x3) != 0" 正在检查最低 2 位是否不是 0。因此,源指针是否与 4 字节的倍数对齐。发生编译错误是因为它将 0x3 视为字节而不是 in,您可以通过使用 0x00000003 或 0x3i(我认为)来解决此问题。
  • b_src &amp; 0x3 不会编译,因为不允许对指针类型进行按位运算。您必须先将其转换为 (u)intptr_t
【解决方案3】:

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

来自one example implementation

00026 * 为了快速复制,优化两个指针的常见情况 00027 * 和长度是字对齐的,而是一次复制一个字 00028 * 一个字节。否则,按字节复制。

【讨论】:

  • 在没有板载缓存的 386(例如)上,这确实产生了巨大的影响。在大多数现代处理器上,读取和写入一次只发生一个高速缓存行,而内存总线通常是瓶颈,因此预计会提高几个百分点,而不是接近四倍。
  • 我认为当您说“从源头上”时,您应该更明确一点。当然,这是某些体系结构的“源”,但肯定不是在 BSD 或 Windows 机器上。 (见鬼,即使在 GNU 系统之间,这个功能也经常有很多差异)
  • @Billy ONeal:+1 绝对正确……给猫剥皮的方法不止一种。那只是一个例子。固定的!感谢您的建设性意见。
【解决方案4】:

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

  1. 使用更大的单位,例如 32 位字而不是字节。您也可以(或可能必须)在此处处理对齐问题。例如,在某些平台上,您不能将 32 位字读/写到奇数内存位置,而在其他平台上,您会付出巨大的性能损失。要解决此问题,地址必须是可被 4 整除的单位。对于 64 位 CPU,您可以将其提升至 64 位,或者使用SIMD(单指令,多数据)指令(MMX,@987654323)甚至更高@等)

  2. 您可以使用编译器可能无法从 C 优化的特殊 CPU 指令。例如,在 80386 上,您可以使用“rep”前缀指令 +“movsb”指令来移动指定的 N 个字节通过将 N 放入计数寄存器中。好的编译器会为你做这件事,但你可能在一个缺乏好的编译器的平台上。请注意,该示例往往无法很好地展示速度,但结合对齐 + 更大的单元指令,它可能比某些 CPU 上的大多数其他东西都快。

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

例如,http://www.agner.org/optimize/#asmlib 有一个 memcpy 实现,它击败了大多数(以非常小的数量)。如果您阅读源代码,您会发现其中包含大量内联汇编代码,这些代码实现了上述所有三种技术,并根据您运行的 CPU 选择其中哪一种技术。

注意,对于在缓冲区中查找字节也可以进行类似的优化。 strchr() 和朋友们通常会比你的手卷更快。对于.NETJava 尤其如此。例如,在 .NET 中,内置的 String.IndexOf() 甚至比 Boyer–Moore string search 快得多,因为它使用了上述优化技术。

【讨论】:

  • 您链接到的同一个 Agner Fog 也认为 loop unrolling is counterproductive on modern CPUs.
  • 现在大多数 CPU 都有很好的分支预测,这应该会抵消循环展开在典型情况下的好处。一个好的优化编译器有时仍然可以使用它。
【解决方案5】:

我不知道它是否实际用于memcpy 的任何实际实现中,但我认为Duff's Device 值得在这里提及。

来自Wikipedia

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,它只是提到了一种相当奇怪的技术。
  • @Daemin 同意,正如您所说,您可以跳过 do {} while() 并且编译器会将开关转换为跳转表。当您想要处理剩余数据时非常有用。应该提到关于 Duff 的设备的警告,显然在较新的架构(较新的 x86)上,分支预测非常有效,以至于 Duff 的设备实际上比简单的循环慢。
  • 哦不..不是达夫的设备。请不要使用达夫的设备。请。使用 PGO,让我的编译器在有意义的地方为你做循环展开。
  • 不,Duff 的设备绝对不会在任何现代实施中使用。
【解决方案6】:

简答:

  • 缓存填充
  • 在可能的情况下传输字大小而不是字节大小
  • SIMD 魔法

【讨论】:

    【解决方案7】:

    就像其他人所说的 memcpy 复制大于 1 字节的块。以字大小的块复制要快得多。然而,大多数实现更进一步,在循环之前运行几个 MOV(字)指令。例如,每个循环复制 8 个字块的优点是循环本身的成本很高。这种技术将条件分支的数量减少了 8 倍,优化了巨型块的副本。

    【讨论】:

    • 我认为这不是真的。您可以展开循环,但您不能在一条指令中复制比目标架构上一次可寻址更多的数据。另外,展开循环也有开销......
    • @Billy ONeal:我不认为这就是 VoidStar 的意思。通过有几个连续的移动指令,计算单元数的开销减少了。
    • @Billy ONeal:你没有抓住重点。一次 1 个字就像 MOV、JMP、MOV、JMP 等。你可以在哪里做 MOV MOV MOV MOV JMP。我之前写过 mempcy 并且我已经对很多方法进行了基准测试;)
    • @wallyk:也许吧。但他说“复制更大的块”——这实际上是不可能的。如果他的意思是循环展开,那么他应该说“大多数实现更进一步,展开循环”。所写的答案充其量是误导,最坏的情况是错误的。
    • @VoidStar:同意——现在好多了。 +1。
    【解决方案8】:

    答案很好,但如果你仍然想自己实现一个快速的memcpy,有一篇关于快速 memcpy 的有趣博文,Fast memcpy in 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;
    }
    

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

    【讨论】:

      【解决方案9】:

      因为与许多库例程一样,它已针对您运行的架构进行了优化。其他人发布了可以使用的各种技术。

      如果可以选择,请使用库例程而不是自己编写。这是 DRY 的一个变体,我称之为 DRO(不要重复其他)。此外,与您自己的实现相比,库例程出错的可能性更小。

      我看到内存访问检查器抱怨内存或字符串缓冲区的越界读取不是字长的倍数。这是正在使用的优化的结果。

      【讨论】:

        【解决方案10】:

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

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

        C memset、memcpy 和 memmove 实现只是跳转到那个固定位置。

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

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

        【讨论】:

        • 苹果开源版本的 memset/memcpy/memmove 只是一个通用版本,会比使用 SIMD 的真实版本慢很多
        猜你喜欢
        • 2015-04-21
        • 1970-01-01
        • 2010-11-15
        • 2011-04-11
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2011-07-27
        相关资源
        最近更新 更多