【问题标题】:Memcpy takes the same time as memsetMemcpy 与 memset 花费相同的时间
【发布时间】:2017-08-27 08:57:22
【问题描述】:

我想使用memcpy 测量内存带宽。我修改了这个答案的代码:why vectorizing the loop does not have performance improvement,它使用memset 来测量带宽。问题是memcpy 只比memset 慢一点,而我预计它会慢两倍,因为它在两倍的内存上运行。

更具体地说,我通过以下操作运行了 1 GB 数组 ab(分配将 calloc)100 次。

operation             time(s)
-----------------------------
memset(a,0xff,LEN)    3.7
memcpy(a,b,LEN)       3.9
a[j] += b[j]          9.4
memcpy(a,b,LEN)       3.8

注意memcpy 只比memset 慢一点。操作a[j] += b[j](其中j 超过[0,LEN))应该比memcpy 长三倍,因为它对三倍的数据进行操作。然而,它的速度只有memset 的 2.5 倍左右。

然后我用memset(b,0,LEN)b 初始化为零并再次测试:

operation             time(s)
-----------------------------
memcpy(a,b,LEN)       8.2
a[j] += b[j]          11.5

现在我们看到memcpy 的速度大约是memset 的两倍,而a[j] += b[j] 的速度大约是memset 的三倍,正如我预期的那样。

至少在memset(b,0,LEN) 之前,我预计memcpy 在100 次迭代中的第一次将是slower because the of lazy allocation (first touch)

为什么我只能在memset(b,0,LEN) 之后得到预期的时间?

test.c

#include <time.h>
#include <string.h>
#include <stdio.h>

void tests(char *a, char *b, const int LEN){
    clock_t time0, time1;
    time0 = clock();
    for (int i = 0; i < 100; i++) memset(a,0xff,LEN);
    time1 = clock();
    printf("%f\n", (double)(time1 - time0) / CLOCKS_PER_SEC);

    time0 = clock();
    for (int i = 0; i < 100; i++) memcpy(a,b,LEN);
    time1 = clock();
    printf("%f\n", (double)(time1 - time0) / CLOCKS_PER_SEC);

    time0 = clock();
    for (int i = 0; i < 100; i++) for(int j=0; j<LEN; j++) a[j] += b[j];
    time1 = clock();
    printf("%f\n", (double)(time1 - time0) / CLOCKS_PER_SEC);

    time0 = clock();
    for (int i = 0; i < 100; i++) memcpy(a,b,LEN);
    time1 = clock();
    printf("%f\n", (double)(time1 - time0) / CLOCKS_PER_SEC);

    memset(b,0,LEN);
    time0 = clock();
    for (int i = 0; i < 100; i++) memcpy(a,b,LEN);
    time1 = clock();
    printf("%f\n", (double)(time1 - time0) / CLOCKS_PER_SEC);

    time0 = clock();
    for (int i = 0; i < 100; i++) for(int j=0; j<LEN; j++) a[j] += b[j];
    time1 = clock();
    printf("%f\n", (double)(time1 - time0) / CLOCKS_PER_SEC);
}

main.c

#include <stdlib.h>

int tests(char *a, char *b, const int LEN);

int main(void) {
    const int LEN = 1 << 30;    //  1GB
    char *a = (char*)calloc(LEN,1);
    char *b = (char*)calloc(LEN,1);
    tests(a, b, LEN);
}

使用 (gcc 6.2) gcc -O3 test.c main.c 编译。 Clang 3.8 给出了基本相同的结果。

测试系统:i7-6700HQ@2.60GHz (Skylake), 32 GB DDR4, Ubuntu 16.10。在我的 Haswell 系统上,带宽在 memset(b,0,LEN) 之前是有意义的,即我只在我的 Skylake 系统上看到了问题。

我首先从a[j] += b[k] 操作in this answer 中发现了这个问题,它高估了带宽。


我想出了一个更简单的测试

#include <time.h>
#include <string.h>
#include <stdio.h>

void __attribute__ ((noinline))  foo(char *a, char *b, const int LEN) {
  for (int i = 0; i < 100; i++) for(int j=0; j<LEN; j++) a[j] += b[j];
}

void tests(char *a, char *b, const int LEN) {
    foo(a, b, LEN);
    memset(b,0,LEN);
    foo(a, b, LEN);
}

这个输出。

9.472976
12.728426

但是,如果我在 calloc 之后在 main 中执行 memset(b,1,LEN)(见下文),那么它会输出

12.5
12.5

这让我认为这是操作系统分配问题,而不是编译器问题。

#include <stdlib.h>

int tests(char *a, char *b, const int LEN);

int main(void) {
    const int LEN = 1 << 30;    //  1GB
    char *a = (char*)calloc(LEN,1);
    char *b = (char*)calloc(LEN,1);
    //GCC optimizes memset(b,0,LEN) away after calloc but Clang does not.
    memset(b,1,LEN);
    tests(a, b, LEN);
}

【问题讨论】:

  • 有很多事情可以在后台产生影响。例如,除非您确保您的分配正确对齐,否则可能会或可能不会使用内在函数,从而导致时序变化而代码没有其他变化。如果您真的想追求这一点,我认为您最好分析生成的程序集,而不是在 C 级别查看它。
  • @DavidHoelzer,你说得对,我应该看看组装。我不知道为什么我没有。我通常这样做。我刚刚在 main.c (单独的目标文件)中尝试了memset,它没有任何区别。这表示它必须是编译器问题,而不是操作系统分配问题。顺便说一句,在我发现这个(不是在这个问题中)的原始测试中,数组必须是 32 字节对齐的。
  • @DavidHoelzer,快速浏览一下程序集,我看不出memset(b,0,LEN) 有什么不同。这是一个简单的版本godbolt.org/g/z6EM2b。我测试了这个简单的版本,在memset之前还是太快了。
  • Then I initialized b to zero with memset(b,0,LEN) and test again: 如果内存之前是未初始化的(但通过 malloc 新获得),它可能已映射到 /dev/zero(预计稍后会被 COWed)。并且 dev/zero 非常快......并且它会产生更少的缓存未命中。找出答案的最佳方法是在此过程中监控 RSS
  • restrict 现在与众不同。我看到你关于编译器在memcpy 之前优化掉memset 的观点。 GCC 和 Clang 都没有这样做,我不知道为什么。 GCC 确实在 calloc 离开之后优化了 memset(0),但 Clang 没有。

标签: c linux memory x86 malloc


【解决方案1】:

您的b 数组可能没有在mmap-ing 之后写入(使用 malloc/calloc 的巨大分配请求通常转换为mmap)。并且整个数组被映射到单个只读“零页”(COW mechanism 的一部分)。从单页读取零比从多页读取要快,因为单页将保存在缓存和 TLB 中。这解释了为什么在 memset(0) 之前的测试更快:

这个输出。 9.472976 12.728426

但是,如果我在 calloc 之后在 main 中执行 memset(b,1,LEN)(见下文),那么它会输出:12.5 12.5

以及更多关于 gcc 的 malloc+memset / calloc+memset 优化为 calloc(扩展自 my comment

//GCC optimizes memset(b,0,LEN) away after calloc but Clang does not.

此优化是由Marc Glisse (https://stackoverflow.com/users/1918193?) 于 2013 年 6 月 27 日在 https://gcc.gnu.org/bugzilla/show_bug.cgi?id=57742 (tree-optimization PR57742) 中提出的,按计划用于 4.9/5.0 版本的 GCC:

memset(malloc(n),0,n) -> calloc(n,1)

calloc 有时可能比 malloc+bzero 快得多,因为它具有某些内存已经为零的特殊知识。当其他优化将一些代码简化为 malloc+memset(0) 时,最好用 calloc 替换它。可悲的是,我认为没有办法在 C++ 中使用 new 进行类似的优化,这是最容易出现此类代码的地方(例如创建 std::vector(10000) )。还有一个复杂的问题是 memset 的大小会比 malloc 的小一点(使用 calloc 仍然可以,但很难知道它是否是一种改进)。

于 2014 年 6 月 24 日实施 (https://gcc.gnu.org/bugzilla/show_bug.cgi?id=57742#c15) - https://gcc.gnu.org/viewcvs/gcc?view=revision&revision=211956(也称为 https://patchwork.ozlabs.org/patch/325357/

  • tree-ssa-strlen.c ... (handle_builtin_malloc, handle_builtin_memset):新功能。

gcc/tree-ssa-strlen.chttps://github.com/gcc-mirror/gcc/blob/7a31ada4c400351a35ab65f8dc0357e7c88805d5/gcc/tree-ssa-strlen.c#L1889 中的当前代码 - 如果memset(0)malloccalloc 获取指针,它将把malloc 转换为calloc,然后memset(0) 将被删除:

/* Handle a call to memset.
   After a call to calloc, memset(,0,) is unnecessary.
   memset(malloc(n),0,n) is calloc(n,1).  */
static bool
handle_builtin_memset (gimple_stmt_iterator *gsi)
 ...
  if (code1 == BUILT_IN_CALLOC)
    /* Not touching stmt1 */ ;
  else if (code1 == BUILT_IN_MALLOC
       && operand_equal_p (gimple_call_arg (stmt1, 0), size, 0))
    {
      gimple_stmt_iterator gsi1 = gsi_for_stmt (stmt1);
      update_gimple_call (&gsi1, builtin_decl_implicit (BUILT_IN_CALLOC), 2,
              size, build_one_cst (size_type_node));
      si1->length = build_int_cst (size_type_node, 0);
      si1->stmt = gsi_stmt (gsi1);
    }

这在 2014 年 3 月 1 日至 2014 年 7 月 15 日的 gcc-patches 邮件列表中进行了讨论,主题为“calloc = malloc + memset

Andi Kleen (http://halobates.de/blog/, https://github.com/andikleen) 发表了重要评论:https://gcc.gnu.org/ml/gcc-patches/2014-06/msg01818.html

FWIW 我相信转型会打破各种各样的微观 基准。

calloc 内部知道来自操作系统的新内存已清零。但 内存可能还没有出现故障。

memset 总是在内存中出错。

所以如果你有一些类似的测试

   buf = malloc(...)
   memset(buf, ...) 
   start = get_time();
   ... do something with buf
   end = get_time()

现在时间将完全关闭,因为测量的时间 包括页面错误。

Marc replied "好点子。我想围绕编译器优化工作是微基准测试游戏的一部分,如果编译器没有以新颖有趣的方式经常搞砸,他们的作者会感到失望; -)" 和Andi asked: "我宁愿不这样做。我不确定它是否有很多好处。如果你想保留它,请确保有一个简单的方法把它关掉。"

Marc 展示了如何关闭此优化:https://gcc.gnu.org/ml/gcc-patches/2014-06/msg01834.html

这些标志中的任何一个都有效:

  • -fdisable-tree-strlen
  • -fno-builtin-malloc
  • -fno-builtin-memset(假设您在代码中明确编写了“memset”)
  • -fno-builtin
  • -ffreestanding
  • -O1
  • -Os

在代码中,您可以隐藏传递给memset 的指针是 一个由malloc 返回,将其存储在volatile 变量中,或者 我们正在做的任何其他隐藏编译器的技巧 memset(malloc(n),0,n).

【讨论】:

    【解决方案2】:

    重点是malloccalloc在大多数平台上 不分配内存;他们分配地址空间

    malloc 等工作:

    • 如果请求可以由空闲列表完成,则从中切出一个块
      • calloc 的情况下:发出memset(ptr, 0, size) 的等价物
    • 如果不是:请求操作系统扩展地址空间。

    对于具有请求分页 (COW) 的系统(MMU 可以提供帮助),第二个选项可以归结为:

    • 为请求创建足够的页表条目,并用对/dev/zero的(COW)引用填充它们
    • 将这些PTEs添加到进程的地址空间

    这不会消耗物理内存,除了页表。

    • 一旦新内存被引用用于读取,读取将来自/dev/zero/dev/zero 设备是一个非常特殊的设备,在这种情况下映射到新内存的每一页
    • 但是,如果新页面被写入,COW 逻辑就会启动(通过页面错误):
      • 物理内存已分配
      • /dev/zero 页面被复制到新页面
      • 新页面与母页面分离
      • 调用进程终于可以进行更新,这一切都开始了

    【讨论】:

    • 我编辑了您的答案以清理一些拼写错误并添加了一些链接和格式。希望你不要介意。
    • 所以我现在明白了。谢谢。这是一种优化(这是 COW 的重点)。如果内存为零,则无需浪费空间,并且从单个零页面读取也比从多个页面读取要快。有趣的是,在这种情况下,GCC 将 malloc 转换为 calloc(但 Clang 不会)并且 memset(0)malloc 之后写入被忽略。因此,除非您正在寻找的答案没有经过优化,否则代码会得到正确的答案。一般来说,我应该将随机数据写入数组并读取它。
    • 每一页都会指向同一个零填充的内存页,并且都将具有COW状态。 (最初)而 Calloc() 并没有这样做,它只调用 mmap() 或 sbrk,而操作系统会做这些脏活。
    • @osgx,我只知道我观察到的。 GCC drops the memset but Clang does not。而here 可以看到 GCC 将 malloc+memset 转换为 calloc。我说过即使没有 memset,GCC 也会将 malloc 转换为 calloc,但我现在看不到这一点,所以我没有证据证明这一点。
    • @Zboson,可能是 2014 年左右的“simplify_malloc_memset”之后的变体gcc.gnu.org/ml/gcc-patches/2014-03/msg00076.htmlRe: calloc = malloc + memset”(2013 gcc bug #57742 for 4.9 /5.0 gcc);和 gcc 错误 67618 “这种优化确实无效的一种情况:当您编译 calloc() 的实现时”。代码位于gcc/tree-ssa-strlen.c:handle_builtin_memsetgithub.com/gcc-mirror/gcc/blob/…(靠近 update_gimple_call)。 Andi Kleen:“...将打破大量的微观基准。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-02-18
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多