清空一个小整数数组:memset和for循环的区别

59

有两种方法可以将整数/浮点数数组归零:

memset(array, 0, sizeof(int)*arraysize);
或:
for (int i=0; i <arraysize; ++i)
    array[i]=0;

显然,对于大的arraysizememset更快。然而,在什么情况下,memset的开销实际上比for循环的开销更大?例如,对于大小为5的数组-哪种方法最好?第一种、第二种,或者展开循环版本:

array[0] = 0;
array[1] = 0;
array[2] = 0;
array[3] = 0;
array[4] = 0;

4
我看不出有什么有趣的部分。唯一真正的答案是在给定平台/编译器上对三个版本进行基准测试。此外,知道答案并没有什么实际用处,对吧?但为了高效地编写基准测试代码 ;) - neuro
是的,我计划自己完成这个。但现在整个互联网都会知道!(至少对于使用mingw的Windows系统而言...) - Claudiu
我只是想知道你是怎么想到那个问题的? - Artem Barger
我正在处理别人的代码,其中到处都使用了for循环。我正在尝试对其进行优化,因此我正在将它们转换为memsets。我想知道这样做是否正确。 - Claudiu
7
你没有提到第三个版本:int array[5] = {0}; - Hosam Aly
6
这只是在声明时进行初始化的方法。本文中提供的形式可以随时用来清除现有数组。 - nobody
4个回答

49
很有可能,memset()将被编译器内联(大多数编译器将其视为“内部函数”,这基本上意味着它是内联的,除非在最低优化级别或明确禁用时)。
例如,这里是 GCC 4.3的发布说明
代码块移动(memcpy)和块设置(memset)的代码生成已被重写。 GCC现在可以根据所复制的块的大小和正在进行优化的CPU选择最佳算法(循环、展开循环、带rep前缀的指令或库调用)。 新增了一个选项-minline-stringops-dynamically。使用此选项会扩展未知大小的字符串操作,以使小块通过内联代码复制,而对于大块则使用库调用。 当库实现能够使用缓存层次结构提示时,这比-minline-all-stringops更快。 可以通过-mstringop-strategy覆盖选择特定算法的启发式方法。新的还有 memset 的值不为0时也被内联。
编译器可能可以对您给出的替代示例执行类似的操作,但我敢打赌这种可能性较小。
而且它可grep并且一目了然看出意图(尽管循环也不是特别难懂)。

10
这是传统说法中“编译器比你更优化”的一个很好的例子。很少有应用程序员会花这么多精力在一个单独的调用上(即使他们这样做了,他们的实际应用程序也可能会受到影响)。 :) - unwind
2
如果你认为“编译器比你更优秀”是应该遵循的原则,那就看看这个链接:http://www.liranuna.com/sse-intrinsics-optimizations-in-popular-compilers/。解开编译器的奥秘。 - LiraNuna
1
@LiraLuna - 这是一篇非常有趣的文章,但我想你会同意,在编译器代码生成方面,memset()/memcpy()与SSE内部函数不在同一级别。此外,您只需要对真正性能关键(或作为学术演习)且值得深入关注的代码进行您所做的分析水平,而不是每个缓冲区复制或清除操作都要这样做。 - Michael Burr
1
@LiraNuna:6年后的更新:目前clang比gcc拥有更强大的SIMD内在优化。特别是对于洗牌操作,clang甚至不关心你最初使用了哪些内在函数;它只会自己选择要发出的指令以达到相同的洗牌结果。当你尝试手动调整一个序列时,这实际上可能会有所损失,因为clang当前的选择并不完美,但在大多数情况下,这很棒。(而且在未来,当clang/LLVM的优化器知道更多情况下的最佳洗牌方式时,这将不再是问题。) - Peter Cordes

24

正如Michael已经指出的那样,gcc和我猜想其他大多数编译器已经对此进行了很好的优化。例如,gcc将这个转换为:

char arr[5];
memset(arr, 0, sizeof arr);

进入

movl  $0x0, <arr+0x0>
movb  $0x0, <arr+0x4>

再也找不到比这更好的了...


对于稍微大一点的数组,可以使用64位寄存器或SIMD一次性清零8/16/32...字节。 - phuclv

9

没有测量就无法回答这个问题。这完全取决于编译器、CPU和运行时库的实现。

memset()可能会有一点"代码异味",因为它容易受到缓冲区溢出、参数反转的影响,并且只能以'字节方式'清除。然而,在极端情况下,它很可能是最快的。

我倾向于使用一个宏来包装它,以避免一些问题:

#define CLEAR(s) memset(&(s), 0, sizeof(s))

这种方法可以避免大小计算,并消除了交换长度和值参数的问题。

简而言之,在“幕后”使用memset()函数。写出你想要的内容,让编译器来处理优化。大多数编译器都非常擅长此项工作。


1
我认为您在宏参数中使用了(x),但在实际主体中使用了(s)...可能需要进行编辑。 - micmoo
@Roddy:顺便说一下,那是讽刺的 :) 关于当前“宏不好”的信仰,我是一个虔诚的怀疑者。 - Mike Dunlavey
8
您的宏会使得这样的代码难以发现问题:char* foo = malloc(80); CLEAR(foo); - Dan Breslau
@Dan,嗯,我认为这并不比char* foo = malloc(80); memset(&foo, 0, 80);或者memset(foo, 80, 0);更难发现。 - Roddy
1
@Roddy,这个讨论中肯定存在主观性。根据我的经验,像这样的宏调用会增加更多的混淆而不值得。有经验的程序员在阅读你刚写的memset调用时可能会立即反应过来。但是看到char* foo = malloc(80); CLEAR(foo);中的错误需要知道CLEAR的定义。(可以说,有经验的程序员会立即知道对于动态分配的指针没有合理的CLEAR定义;但这不是我想依赖的那种经验。) - Dan Breslau
显示剩余2条评论

1

就代码本身而言,一切都已经讲清楚了。但是如果您考虑它在其程序中的作用,我不知道任何东西,可以做一些其他的事情。例如,如果要每隔一段时间执行此代码以清除数组,则可以运行一个线程,不断分配新的零元素数组并分配给全局变量,当需要清除数组时,您的代码只需指向该数组。

这是第三个选项。当然,如果您计划在至少具有两个核心的处理器上运行代码并且这是有意义的。此外,必须多次运行代码才能看到好处。对于仅运行一次的情况,您可以声明一个填充为零的数组,然后在需要时指向它。

希望这可以帮助某些人


这只适用于相当大的数组。在小数组中,由于在一个核心上清零并在另一个核心上使用会导致缓存未命中开销,更不用说同步开销了。我猜单独的清零线程只有在大于16kiB的数组中才值得考虑。(现代Intel CPU的L1缓存大小为32kiB) - Peter Cordes
释放旧数组并分配新的清零内存,就像您建议的那样,可能会更便宜,特别是如果大多数页面不会被写入。例如,在Linux上,从mmap新分配的页面都映射到相同的零内存物理页面上。calloc(3)利用了这一点,并且不会污染页面(不像std :: vector)。每个页面的第一次写入将触发软页错误。 - Peter Cordes
我认为每个核心都有自己的L1缓存,所以我不知道缺失会发生在哪里。不过你可能比我更了解缓存。如果数组不经常清除,同步开销就不是问题。当清除线程持有锁时,另一个线程将会做其他事情。真正的缺点是你需要非常了解在“主”线程中数组需要清除的频率,这并不总是可能的。如果不可能,我的想法就行不通,并且会大大减慢一切。 - fedeb
每个核心都有私有的L1缓存,这正是问题所在:数组将在运行清理线程的核心的L1缓存中变得热门,而不是尝试使用它的核心。在同一线程中对小数组进行memset会使该内存在L1中保持热门状态,因此后续的写入速度很快。如果它是由另一个线程最后写入的,则最多会在共享的L3缓存中保持热门状态(自 Nehalem 以来,Intel CPU使用大型包容性L3缓存,因此一致性流量不必通过主存储器)。在最坏的情况下,它仍然处于其他核心的L1中的修改状态(MOESI)。 - Peter Cordes
对于大型数组,比如几 MiB 左右的数组,维护一个零化数组池可能有点意义。不过,我不确定与让操作系统为您执行此操作并使用 calloc 相比是否会获得更多好处。对于较小的数组,memset 的成本非常低。如果您在清零后立即访问数组,则应该在同一线程中同时执行两个操作。 - Peter Cordes
很好的缓存解释 - fedeb

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接