循环复制是否比 memcpy() 效率低?

Is copying in a loop less efficient than memcpy()?

我开始学IT了,现在正在和一个朋友讨论这段代码是不是低效

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

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

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

如果有更有效的方法或者这种方法效率低下,请告诉我为什么!

提前致谢!

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

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

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

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

char *dest = m_pName;

for (char const *in = pName; *in; ++in)
    *dest++ = *in;
*dest++ = '[=10=]';

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

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

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

He is claiming that for example memcopy would do the same like the for loop above.

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


顺便说一句,来自您评论的代码:

// char *m_pName = nullptr;

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

分配内存
char *m_pName = new char[strlen(pName) + 1];

为什么 +1?因为你要考虑放一个'[=19=]'表示c风格字符串的结尾。

是的,您的代码效率低下。您的代码花费了所谓的 "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 将使用您系统的字长。

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

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

是的,它效率低下,不是因为您使用循环而不是 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 的回答中的循环相同。

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

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

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

它知道哪一个最适合您的情况。或者至少它可能比你更了解。当你事后猜测你 运行 编译器无法识别它的风险 你的版本比编译器 and/or 库知道的收集的聪明技巧更糟糕.

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

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

更进一步;因为 memcpy() 是一个如此基本的操作,所以即使 硬件 也有可能识别编译器试图做什么,并实现其自己的快捷方式,即使是编译器也不知道。

不用担心 strlen() 的多余调用。编译器可能也知道这一点。(编译器在某些情况下应该知道,但似乎并不关心)编译器看到了所有。编译器知道一切。编译器在你睡觉的时候看着你。 相信编译器。

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

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

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

我以为它会被吊起,并预计这里的主要低效率只是一次字节复制循环。但不,它真的和其他答案所暗示的一样糟糕。 m_pName 甚至每次都必须重新加载,因为别名规则允许 m_pName[i] 别名 this->m_pName编译器不能假设存储到 m_pName[i] 不会改变 class 成员变量,或 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 循环计数器副本,这在 int isize_t i 中都没有clang 和 gcc。大概他们很难解释 unsigned i 可能产生无限循环的事实。

很明显这太可怕了:

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

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

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

memcpystrcpy 更有效,因为 rep movs 在 Intel CPU 上进行了高度优化,尤其是。 IvB 及更高版本。然而,首先扫描字符串以找到正确的长度总是比差值花费更多。当您已经知道数据的长度时使用 memcpy

这段代码在很多方面都很混乱。

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

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

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

  4. 无论如何,让 strlen 脱离循环。

  5. 现在是担心性能的错误时间。 先说对了

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