【问题标题】:Is copying in a loop less efficient than memcpy()?在循环中复制是否比 memcpy() 效率低?
【发布时间】:2016-02-13 20:48:46
【问题描述】:

我开始学习IT,现在正在和朋友讨论这段代码是否效率低。

// const char *pName
// char *m_pName = nullptr;

for (int i = 0; i < strlen(pName); i++)
    m_pName[i] = pName[i];

他声称例如 memcopy 会像上面的 for 循环一样做。我不知道这是不是真的,我不相信。

如果有更有效的方法或者效率低下,请告诉我原因!

提前致谢!

【问题讨论】:

标签: c++ performance loops memcpy strlen


【解决方案1】:

我查看了actual g++ -O3 output for your code,看看它有多糟糕。

char* 可以给任何东西起别名,因此即使是__restrict__ GNU C++ 扩展也无法帮助编译器将strlen 提升到循环之外。

我认为它会被提升,并期望这里的主要低效率只是一个字节一次的复制循环。但不,它真的和其他答案所暗示的一样糟糕。 m_pName 甚至每次都必须重新加载,因为别名规则允许 m_pName[i] 别名为 this-&gt;m_pName编译器不能假设存储到m_pName[i] 不会更改类成员变量、src 字符串或其他任何内容。

#include <string.h>
class foo {
   char *__restrict__ m_pName = nullptr;
   void set_name(const char *__restrict__ pName);
   void alloc_name(size_t sz) { m_pName = new char[sz]; }
};

// g++ will only emit a non-inline copy of the function if there's a non-inline definition.
void foo::set_name(const char * __restrict__ pName)
{
    // char* can alias anything, including &m_pName, so the loop has to reload the pointer every time
    //char *__restrict__ dst = m_pName;  // a local avoids the reload of m_pName, but still can't hoist strlen
    #define dst m_pName
    for (unsigned int i = 0; i < strlen(pName); i++)
        dst[i] = pName[i];
}

编译为此 asm(g++ -O3 for x86-64, SysV ABI):

...
.L7:
        movzx   edx, BYTE PTR [rbp+0+rbx]      ; byte load from src.  clang uses mov al, byte ..., instead of movzx.  The difference is debatable.
        mov     rax, QWORD PTR [r12]           ; reload this->m_pName    
        mov     BYTE PTR [rax+rbx], dl         ; byte store
        add     rbx, 1
.L3:                                 ; first iteration entry point
        mov     rdi, rbp                       ; function arg for strlen
        call    strlen
        cmp     rbx, rax
        jb      .L7               ; compare-and-branch (unsigned)

使用unsigned int 循环计数器会引入一个额外的循环计数器mov ebx, ebp 副本,而在clang 和gcc 中,int isize_t i 都没有。据推测,他们很难考虑unsigned i 可能会产生无限循环这一事实。

显然这很可怕:

  • strlen 调用复制的每个字节
  • 一次复制一个字节
  • 每次循环都重新加载m_pName(可以通过将其加载到本地来避免)。

使用strcpy 可以避免所有这些问题,因为允许strlen 假定它的src 和dst 不重叠。 不要使用strlen + memcpy,除非你想知道strlen自己。如果strcpy 的最有效实现是strlen + memcpy,则库函数将在内部执行此操作。否则,它会做一些更有效的事情,比如glibc's hand-written SSE2 strcpy for x86-64。 (有一个SSSE3 version,但它在 Intel SnB 上实际上速度较慢,而且 glibc 足够聪明,不会使用它。)即使是 SSE2 版本也可能展开比它应有的更多(在微基准上很好,但会污染指令缓存, uop-cache 和分支预测器缓存(用作实际代码的一小部分)。大部分复制在 16B 块中完成,64 位、32 位和更小的块位于启动/清理部分。

当然,使用strcpy 也可以避免忘记在目标中存储尾随'\0' 字符等错误。如果您的输入字符串可能很大,使用int 作为循环计数器(而不是size_t)也是一个错误。使用strncpy 通常会更好,因为您通常知道 dest 缓冲区的大小,但不知道 src 的大小。

memcpy 可能比strcpy 更高效,因为rep movs 在 Intel CPU 上进行了高度优化,尤其是。 IVB 及更高版本。但是,首先扫描字符串以找到正确的长度总是比差价要高。如果您已经知道数据的长度,请使用memcpy

【讨论】:

    【解决方案2】:

    充其量只是效率低下。在最坏的情况下,它相当效率低下。

    在好的情况下,编译器认识到它可以将对strlen 的调用提升到循环之外。在这种情况下,您最终会遍历输入字符串一次以计算长度,然后再次复制到目的地。

    在不好的情况下,编译器会在循环的每次迭代中调用strlen,在这种情况下,复杂度变为二次而不是线性。

    至于如何有效地做到这一点,我倾向于这样:

    char *dest = m_pName;
    
    for (char const *in = pName; *in; ++in)
        *dest++ = *in;
    *dest++ = '\0';
    

    这仅遍历输入一次,因此它可能比第一次快两倍,即使在更好的情况下(在二次情况下,它可能会快 很多 倍,具体取决于字符串的长度)。

    当然,这与strcpy 所做的几乎相同。这可能会也可能不会更有效——我当然见过这样的案例。由于您通常认为strcpy 会被大量使用,因此花更多时间优化它可能比互联网上的一些随机人在几分钟内输入答案更值得。

    【讨论】:

    • g++ 不会将strlen 提升到循环之外。我很确定这是因为别名规则允许 char* 对任何东西进行别名。看我的回答。此外,调用strcpy 将为您提供一个优化的库实现,该实现对于大字符串(例如,使用 SSE 一次复制 16B)将执行得更好。当有可能优化的库函数时,应避免“手动”写出字节复制循环。
    【解决方案3】:

    是的,您的代码效率低下。您的代码需要所谓的“O(n^2)”时间。为什么?您的循环中有 strlen() 调用,因此您的代码在每个循环中重新计算字符串的长度。这样做可以加快速度:

    unsigned int len = strlen(pName);
    for (int i = 0; i < len; i++)
        m_pName[i] = pName[i];
    

    现在,您只计算一次字符串长度,因此这段代码需要“O(n)”时间,这比 O(n^2) 快得多。现在大约已经达到了你所能得到的最有效的程度。但是,memcpy 调用仍然会快 4-8 倍,因为此代码一次复制 1 个字节,而 memcpy 将使用您系统的字长。

    【讨论】:

      【解决方案4】:

      取决于对效率的解释。我会声称使用memcpy()strcpy() 更有效,因为您不会在每次需要副本时都编写这样的循环。

      他声称例如 memcopy 会像上面的 for 循环一样做。

      嗯,不完全一样。可能是因为 memcpy() 只占用一次大小,而 strlen(pName) 可能会在每次循环迭代时被调用。因此,从潜在性能效率考虑memcpy()会更好。


      来自您的注释代码的顺便说一句:

      // char *m_pName = nullptr;
      

      这样初始化会导致未定义的行为,而不为m_pName分配内存:

      char *m_pName = new char[strlen(pName) + 1];
      

      为什么是+1?因为你得考虑放一个'\0'来表示c风格字符串的结尾。

      【讨论】:

        【解决方案5】:

        是的,它效率低下,不是因为您使用循环而不是memcpy,而是因为您在每次迭代时都调用strlenstrlen 循环遍历整个数组,直到找到终止的零字节。

        此外,strlen 不太可能在循环条件外进行优化,请参阅In C++, should I bother to cache variables, or let the compiler do the optimization? (Aliasing)

        所以memcpy(m_pName, pName, strlen(pName)) 确实会更快。

        更快的是strcpy,因为它避免了strlen 循环:

        strcpy(m_pName, pName);
        

        strcpy 的作用与@JerryCoffin 答案中的循环相同。

        【讨论】:

          【解决方案6】:

          对于像这样的简单操作,您几乎应该总是说出你的意思,仅此而已。

          在这种情况下,如果您的意思是 strcpy(),那么您应该这么说,因为 strcpy() 将复制终止 NUL 字符,而该循环不会。

          你们谁都无法赢得辩论。现代编译器已经看到了一千种不同的 memcpy() 实现,它很有可能会识别您的并通过调用 memcpy() 或使用它自己的内联实现来替换您的代码。

          它知道哪一个最适合您的情况。或者至少它可能比你更了解。当您再次猜测您冒着编译器无法识别它的风险时并且您的版本比编译器和/或库所知道的收集的聪明技巧更糟糕。

          如果您想运行自己的代码而不是库代码,则必须正确考虑以下几点:

          • 有效的最大读/写块大小是多少(很少是字节)。
          • 对于多大范围的循环长度,是否值得麻烦预先对齐读取和写入以便可以复制更大的块?
          • 是对齐读取、对齐写入、不执行任何操作,还是对齐两者并在算术中执行排列以进行补偿更好?
          • 如何使用 SIMD 寄存器?它们更快吗?
          • 在第一次写入之前应该执行多少次读取?需要使用多少寄存器文件才能实现最高效的突发访问?
          • 是否应该包含预取指令?
            • 还有多远?
            • 多久一次?
            • 循环是否需要额外的复杂性来避免预加载结束?
          • 这些决策中有多少可以在运行时解决而不会造成太多开销?测试会导致分支预测失败吗?
          • 内联会有所帮助,还是只是浪费 icache?
          • 循环代码是否受益于高速缓存行对齐?是否需要将其紧密打包到单个缓存行中?对同一高速缓存行中的其他指令是否有限制?
          • 目标 CPU 是否有像 rep movsb 这样性能更好的专用指令?是否有它们但它们的表现更差

          走得更远;因为memcpy() 是一个非常基础的操作,所以即使是硬件也可能会识别出编译器正在尝试做什么,并实现自己的快捷方式,甚至编译器都不知道。

          不用担心对strlen() 的多余调用。编译器可能也知道这一点。(编译器在某些情况下应该知道,但它似乎并不在意)编译器可以看到所有内容。编译器知道一切。编译器会在您睡觉时监视您。 相信编译器。

          哦,除了编译器可能无法捕获该空指针引用。愚蠢的编译器!

          【讨论】:

          • +1 用于提到以更大的块进行复制。然而,事实证明strlen 不能被吊出,因为char* 可以给任何东西起别名。此外,不,CPU 通常不会识别块复制循环并对其进行窥孔优化。我从未听说过任何架构这样做。别名规则意味着此循环与strcpy 具有不同的语义,因此编译器无法将其替换为最佳值。
          • @PeterCordes,对于strlen(),你说得对。编译器确实检测到非混叠条件并执行提升,但这些条件比我想象的要少(主要是由于未能在对象结束之前终止字符串,但结果应该无论如何都是未定义的,所以提升应该是良性的......但 gcc 不这么认为)。我不希望 CPU 窥视任何东西(尽管软的可能),而是为已知习语实现管道优化。不过,由于各种原因,它们经常没有证件。
          • 什么叫做“管道优化”? agner.org/optimize 非常详细地记录了现代 x86 CPU 的管道。管道和 OOO 机器处理方式不同的复制循环没有什么特别之处。它只是一堆字节加载和存储。如果加载和存储地址之间存在错误的别名,它将有额外的减速。 (例如偏移 4k)
          • @PeterCordes 专有 CPU 和 DSP,主要是。通常是有序的管道,否则会因为通过寄存器文件的往返而绊倒在紧密的 memcpy 循环中。
          • 哦,还有 icache 或指令缓冲区有有趣的约束,您必须确保循环完全在单个高速缓存行内,并且同一高速缓存行或缓冲区中没有其他分支. 确切地不是管道优化,而是编译器不会自动对每个分支或标签施加的约束,因为它会烧掉太多的 icache。
          【解决方案7】:

          这段代码以各种方式混淆。

          1. 只需执行m_pName = pName;,因为您实际上并没有复制字符串。 你只是指向你已经拥有的那个。

          2. 如果你想复制字符串m_pName = strdup(pName);就可以了。

          3. 如果您已经有存储空间,strcpymemcpy 可以。

          4. 无论如何,让strlen退出循环。

          5. 现在是担心性能的错误时机。 先把它弄好。

          6. 如果你坚持担心性能,那是很难打败strcpy的。 更重要的是,您不必担心它是否正确。

          【讨论】:

            【解决方案8】:

            事实上,你为什么需要复制??? (使用循环或 memcpy)

            如果你想复制一个内存块,那是一个不同的问题,但由于它是一个指针,你所需要的只是 &pName[0] (这是数组第一个位置的地址)和 sizeof pName ...它...您可以通过增加第一个字节的地址来引用数组中的任何对象,并且您知道使用大小值的限制...为什么有所有这些指针???(让我知道是否还有更多理论辩论)

            【讨论】:

            • 有时您确实需要复制,例如在将 src 缓冲区用于其他用途之前。最小化副本是一个很好的建议,但不是这个问题的完整答案。
            • 是的,非常正确......但他开始使用指向字符串开头的指针(由空终止定义的字符串结尾)的代码,这是一个非常有效的系统,您可以完全仅使用起始地址和空终止来描述内存块,并尝试为字符串的每个字节创建一个指针,直到空终止。 (为什么?指点???这超出了我的范围!
            • 不,他的循环只是写得很糟糕的strcpy。他没有存储指向每个字符的指针。
            • ohhhh ...我现在明白了...看来我在这里工作太久了!
            猜你喜欢
            • 2012-08-14
            • 2015-04-22
            • 1970-01-01
            • 2013-04-27
            • 1970-01-01
            • 1970-01-01
            • 2011-03-08
            • 2020-10-10
            • 1970-01-01
            相关资源
            最近更新 更多