比memset更快的清零内存的方法?

76

我了解到 memset(ptr, 0, nbytes) 非常快,但是有没有更快的方法(至少在x86上)?

我假设 memset 使用 mov,但是当清零内存时,大多数编译器使用 xor,因为它更快,对吗?编辑1:错误的,正如GregS指出的那样,这只适用于寄存器。我在想什么呢?

另外,我请一位比我更熟悉汇编语言的人来查看stdlib,他告诉我,在x86上,memset没有充分利用32位宽的寄存器。然而,当时我非常累,所以我不确定我是否正确理解了他的话。

编辑2:我重新审视了这个问题,并进行了一些测试。以下是我测试的内容:

    #include <stdio.h>
    #include <malloc.h>
    #include <string.h>
    #include <sys/time.h>

    #define TIME(body) do {                                                     \
        struct timeval t1, t2; double elapsed;                                  \
        gettimeofday(&t1, NULL);                                                \
        body                                                                    \
        gettimeofday(&t2, NULL);                                                \
        elapsed = (t2.tv_sec - t1.tv_sec) * 1000.0 + (t2.tv_usec - t1.tv_usec) / 1000.0; \
        printf("%s\n --- %f ---\n", #body, elapsed); } while(0)                 \


    #define SIZE 0x1000000

    void zero_1(void* buff, size_t size)
    {
        size_t i;
        char* foo = buff;
        for (i = 0; i < size; i++)
            foo[i] = 0;

    }

    /* I foolishly assume size_t has register width */
    void zero_sizet(void* buff, size_t size)
    {
        size_t i;
        char* bar;
        size_t* foo = buff;
        for (i = 0; i < size / sizeof(size_t); i++)
            foo[i] = 0;

        // fixes bug pointed out by tristopia
        bar = (char*)buff + size - size % sizeof(size_t);
        for (i = 0; i < size % sizeof(size_t); i++)
            bar[i] = 0;
    }

    int main()
    {
        char* buffer = malloc(SIZE);
        TIME(
            memset(buffer, 0, SIZE);
        );
        TIME(
            zero_1(buffer, SIZE);
        );
        TIME(
            zero_sizet(buffer, SIZE);
        );
        return 0;
    }

结果:

除了 -O3 之外, zero_1 是最慢的。zero_sizet 是最快的,-O1,-O2 和 -O3 性能大致相等。memset 总是比 zero_sizet 慢。(在 -O3 下慢两倍)。有一件有趣的事情是,在 -O3 下,zero_1 和 zero_sizet 的速度相同。但是,反汇编函数有大约四倍的指令量(我认为这是由于循环展开引起的)。另外,我尝试进一步优化 zero_sizet,但编译器总是做得比我好,但毫无意外。

目前 memset 获胜,以前的结果受 CPU 缓存的影响而失真。(所有测试都在 Linux 上运行)需要进一步测试。我下一步将尝试使用汇编语言:)

编辑3:修复了测试代码中的错误,测试结果不受影响

编辑4:在浏览 VS2010 C 运行时反汇编时,我注意到 memset 具有 SSE 优化的零值例程。这将很难被超越。


6
为什么不尝试反汇编你的编译器输出,而是假设 memset 使用 mov 呢?不同的编译器会采用不同的方式。如果在某种架构上 xor 更快,那么一些编译器将优化 memset(ptr, 0, nbytes)xor 指令也就不足为奇了。 - Laurence Gonsalves
15
我不知道有哪个编译器使用异或(XOR)来清空内存,也许会用寄存器但不会用内存。如果要使用异或来清空内存,必须先读取内存,然后进行异或操作,最后再写入内存。 - President James K. Polk
5
如果适用,calloc 可能是有效地免费的,因为实现可能会在 CPU 空闲时提前零化页面。这算吗?;-) - Steve Jessop
1
你的zero_sizet作弊了,因为它没有执行memset的所有工作。只有在复制大小是sizeof(size_t)的倍数时,它们才相等。在其他情况下,memset也必须将剩余的1到3(7)字节清零。如果地址未对齐,则可能会出现另一个问题,良好的memset实现将尝试在循环之前对其进行size_t*对齐。这在正常情况下会稍微有点惩罚,但在奇怪的情况下会获得很大收益。一种良好的实现还会选择64位对齐作为PC上的内存总线自奔蒂厄姆1开始就是64位。 - Patrick Schlüter
2
@TravisGockel 另外也非常不明智的是: madvise() 可能什么也不做。它可能在 特定的内核和 libc 版本 上运行良好,但如果您升级其中任何一个,它很容易出现问题。 - tc.
显示剩余6条评论
9个回答

40

x86是一个相当广泛的设备范围。

对于完全通用的x86目标,使用"rep movsd"的汇编代码块可以每次向内存写入32位的零。尝试确保大部分工作都是DWORD对齐的。

对于具有mmx的芯片,使用movq的汇编循环可以每次处理64位。

您可能能够让C/C++编译器使用指向long long或_m64的指针进行64位写操作。为了获得最佳性能,目标必须对齐到8字节。

对于具有sse的芯片,movaps很快,但仅在地址对齐到16字节时才有效,因此请使用movsb直到对齐,然后再使用movaps循环完成清除操作。

Win32有"ZeroMemory()"函数,但我不记得它是memset的宏还是实际上的'好的'实现。


9
10岁小朋友的回答,但是ZeroMemory完全是一个memset的宏:D - Hypervisor

30

memset一般被设计为非常快速的通用设置/清零代码。它处理所有带有不同大小和对齐方式的情况,这影响了您可以使用哪种指令来完成工作。根据您所在的系统(以及您的stdlib来自哪个供应商),底层实现可能是针对该体系结构特定的汇编语言,以利用其本地属性。它还可能具有内部特殊情况来处理清零的情况(而不是设置其他值)。

话虽如此,如果您有非常具体、非常性能关键的内存清零任务要完成,您当然可以通过自己实现来击败特定的memset实现。 memset和标准库中的其他函数总是一个很有趣的竞争目标 :)


2
另外:在理论上,memset 可以针对值为 0 的情况有一个特殊的处理,这可以通过编译时选择(通过内联或作为固有操作)来实现,但我不知道是否真的有人这么做。 - Steve Jessop
2
@Steve Jessop:有趣的想法(尤其是它可以在编译时完成)。我记得曾经读过某个人对memset的独创实现,其中包含了几乎所有你实际使用memset的特殊情况。 - Ben Zotto
36
通常情况下,gcc使用一个内联的内置实现来执行 memset() 函数。有趣的是,我记得读到过一个有缺陷的 memset() 实现,它总是将值设置为0 - 而且这个问题在数年时间内都没有被注意到,因为大多数情况下 memset() 用于设置为零! - caf
2
memset通常被设计为非常快速的通用设置/清零代码…” - 我认为这并不完全正确。不能保证memset会经过优化处理而幸存下来,因此可能不会进行零化操作。memset_s提供了这种保证,但Glibc的开发人员拒绝提供它。另请参见问题17879:库缺少memset_s - jww

25
现在,你的编译器应该为你完成所有工作。就我所知,gcc在优化对memset的调用方面非常高效(不过最好检查一下汇编代码)。此外,如果没有必要,也要避免使用memset:
- 对于堆内存,请使用calloc。 - 对于栈内存,请使用适当的初始化方式(... = {0})。 - 对于非常大的块,请使用mmap(如果有的话)。这只需要从系统中获得零初始化的内存,相当于“免费”获取。

不,据我上次检查,gcc没有这样做。然而,g++会优化掉对std::fill的调用(除非启用了优化选项-ftree-loop-distribute-patterns,此时它也会变成对memset的调用),这是C++中类似于memset的函数。 - Hi-Angel
1
值得一提的是:我刚刚进行了一些测试,并发现了一个奇妙的事情:使用-ftree-loop-distribute-patternsstd∷fill更改为memset,程序比不使用该选项快10倍!即使g++内联了std∷fill,甚至加上march=native也是如此。因此,gcc-4.9.2在优化方面并不那么出色,因为这意味着有一种方法可以进一步优化std∷fill。顺便说一下,我还用clang进行了测试,发现它的优化效果更差——在-O3级别下,它甚至没有从代码中删除push-pop - Hi-Angel
1
嗯,“免费”的评论并不完全正确。内存初始化为零只是在操作系统中发生,而不是在程序控制下。谁知道谁编写了mmap函数,它是否高效?如果时间关键性很重要,最好获取未初始化的内存,然后使用汇编程序清除它。 - Cool Javelin
编译器提供了大多数基本函数的内置函数,包括此函数。当可用时,库函数应使用编译器的内置函数。 - Igor Stoppa

6
如果我没记错的话(几年前听高级开发人员说的),其中一位高级开发人员正在谈论在PowerPC上快速执行bzero()的方法(规格说明我们需要在上电时将几乎所有内存清零)。这种方法可能不会很好地(如果有的话)转换到x86,但值得探索。
其想法是加载数据缓存行,清除该数据缓存行,然后将清除的数据缓存行写回内存。
至于它是否有用,希望能帮到你。

1
不需要加载缓存行,只需将零写入缓存行。 - totten

6
除非您有特定的需求或者知道您的编译器/标准库不够好,否则请使用memset。它是通用的,并且在一般情况下应该具有良好的性能。此外,编译器可能更容易优化/内联memset(),因为它可以具有内部支持。
例如,Visual C++经常生成内联版本的memcpy/memset,大小与库函数调用一样小,从而避免了push/call/ret开销。当大小参数可以在编译时评估时,还可以进行进一步的优化。
话虽如此,如果您有特定的需求(其中大小将始终是非常小或非常大),您可以通过降级到汇编级别来获得速度提升。例如,在不污染L2缓存的情况下,使用写入操作来清零大块内存。
但这一切都取决于具体情况 - 对于普通的东西,请坚持使用memset/memcpy :)

1
即使是 SPARC 上的旧版 GCC 实现,当大小在编译时已知且不太大时,也会用 mov 指令替换 memcpymemset 调用。 - Patrick Schlüter

5

memset函数的设计目的是灵活和简单,即使牺牲速度。在许多实现中,它是一个简单的while循环,每次复制指定值一个字节到给定字节数。如果你想要更快的memset(或memcpy、memmove等),几乎总是可以自己编写代码实现。

最简单的自定义方式是执行单字节的“设置”操作,直到目标地址是32位或64位对齐的(与您的芯片架构匹配),然后开始一次性复制完整的CPU寄存器。如果您的范围不以对齐地址结束,您可能还需要进行几个单字节的“设置”操作。

根据您特定的CPU,您可能还有一些流式SIMD指令可以帮助您。这些通常在对齐地址上工作得更好,因此使用对齐地址的上述技术也可以在这里有用。

对于大量内存的清零,通过将范围分成多个部分并同时处理每个部分(其中部分数量与您的核心/硬件线程数相同)可能会提高速度。

最重要的是,除非尝试,否则无法确定这些方法是否有所帮助。至少查看每种情况下编译器生成的内容。还要查看其他编译器为其标准“memset”生成的内容(它们的实现可能比您的编译器更有效率)。


4

这是一个有趣的问题。我编写了这个实现,在VC++ 2012上进行32位发布编译时,速度略微更快(但几乎无法测量)。它可能还可以进一步改进。在多线程环境中将其添加到您自己的类中,可能会给您带来更多的性能提升,因为在多线程情况下,memset()存在一些报告的瓶颈问题。

// MemsetSpeedTest.cpp : Defines the entry point for the console application.
//

#include "stdafx.h"
#include <iostream>
#include "Windows.h"
#include <time.h>

#pragma comment(lib, "Winmm.lib") 
using namespace std;

/** a signed 64-bit integer value type */
#define _INT64 __int64

/** a signed 32-bit integer value type */
#define _INT32 __int32

/** a signed 16-bit integer value type */
#define _INT16 __int16

/** a signed 8-bit integer value type */
#define _INT8 __int8

/** an unsigned 64-bit integer value type */
#define _UINT64 unsigned _INT64

/** an unsigned 32-bit integer value type */
#define _UINT32 unsigned _INT32

/** an unsigned 16-bit integer value type */
#define _UINT16 unsigned _INT16

/** an unsigned 8-bit integer value type */
#define _UINT8 unsigned _INT8

/** maximum allo

wed value in an unsigned 64-bit integer value type */
    #define _UINT64_MAX 18446744073709551615ULL

#ifdef _WIN32

/** Use to init the clock */
#define TIMER_INIT LARGE_INTEGER frequency;LARGE_INTEGER t1, t2;double elapsedTime;QueryPerformanceFrequency(&frequency);

/** Use to start the performance timer */
#define TIMER_START QueryPerformanceCounter(&t1);

/** Use to stop the performance timer and output the result to the standard stream. Less verbose than \c TIMER_STOP_VERBOSE */
#define TIMER_STOP QueryPerformanceCounter(&t2);elapsedTime=(t2.QuadPart-t1.QuadPart)*1000.0/frequency.QuadPart;wcout<<elapsedTime<<L" ms."<<endl;
#else
/** Use to init the clock */
#define TIMER_INIT clock_t start;double diff;

/** Use to start the performance timer */
#define TIMER_START start=clock();

/** Use to stop the performance timer and output the result to the standard stream. Less verbose than \c TIMER_STOP_VERBOSE */
#define TIMER_STOP diff=(clock()-start)/(double)CLOCKS_PER_SEC;wcout<<fixed<<diff<<endl;
#endif    


void *MemSet(void *dest, _UINT8 c, size_t count)
{
    size_t blockIdx;
    size_t blocks = count >> 3;
    size_t bytesLeft = count - (blocks << 3);
    _UINT64 cUll = 
        c 
        | (((_UINT64)c) << 8 )
        | (((_UINT64)c) << 16 )
        | (((_UINT64)c) << 24 )
        | (((_UINT64)c) << 32 )
        | (((_UINT64)c) << 40 )
        | (((_UINT64)c) << 48 )
        | (((_UINT64)c) << 56 );

    _UINT64 *destPtr8 = (_UINT64*)dest;
    for (blockIdx = 0; blockIdx < blocks; blockIdx++) destPtr8[blockIdx] = cUll;

    if (!bytesLeft) return dest;

    blocks = bytesLeft >> 2;
    bytesLeft = bytesLeft - (blocks << 2);

    _UINT32 *destPtr4 = (_UINT32*)&destPtr8[blockIdx];
    for (blockIdx = 0; blockIdx < blocks; blockIdx++) destPtr4[blockIdx] = (_UINT32)cUll;

    if (!bytesLeft) return dest;

    blocks = bytesLeft >> 1;
    bytesLeft = bytesLeft - (blocks << 1);

    _UINT16 *destPtr2 = (_UINT16*)&destPtr4[blockIdx];
    for (blockIdx = 0; blockIdx < blocks; blockIdx++) destPtr2[blockIdx] = (_UINT16)cUll;

    if (!bytesLeft) return dest;

    _UINT8 *destPtr1 = (_UINT8*)&destPtr2[blockIdx];
    for (blockIdx = 0; blockIdx < bytesLeft; blockIdx++) destPtr1[blockIdx] = (_UINT8)cUll;

    return dest;
}

int _tmain(int argc, _TCHAR* argv[])
{
    TIMER_INIT

    const size_t n = 10000000;
    const _UINT64 m = _UINT64_MAX;
    const _UINT64 o = 1;
    char test[n];
    {
        cout << "memset()" << endl;
        TIMER_START;

        for (int i = 0; i < m ; i++)
            for (int j = 0; j < o ; j++)
                memset((void*)test, 0, n);  

        TIMER_STOP;
    }
    {
        cout << "MemSet() took:" << endl;
        TIMER_START;

        for (int i = 0; i < m ; i++)
            for (int j = 0; j < o ; j++)
                MemSet((void*)test, 0, n);

        TIMER_STOP;
    }

    cout << "Done" << endl;
    int wait;
    cin >> wait;
    return 0;
}

32位系统发布编译后的输出如下:

memset() took:
5.569000
MemSet() took:
5.544000
Done

64位系统下,进行发布编译时的输出如下:

memset() took:
2.781000
MemSet() took:
2.765000
Done

这里可以找到伯克利的 memset() 源代码,我认为这是最常见的实现方式。


1
你应该真正使用 QueryPerformanceTimer() 函数族。 - quantum
@xiaomao 这些函数只能提供更多毫秒级别的精度,但我会满足你。在32位Windows上的结果是:memset()花费了:5575.6毫秒。MemSet()花费了:5539.97毫秒。而在64位Windows上的结果是:memset()花费了:2778.73毫秒。MemSet()花费了:2774.18毫秒。 - user152949
2
const _UINT64 m = _UINT64_MAX 看起来有些可疑,因为您后来将其与 int 进行比较。j < o 应该被好的编译器优化掉,而且您对 memset() 不公平,因为它也是预热数组 test 的方法之一。 - tc.
@tc,“什么使数组变热”,是的,如果在单个运行时多次交替使用这两种方法进行测试,那将会很有趣。我可能会在回家后看一下。 - Max

3

这个测试本来很好用,但有一个致命的缺陷: 由于memset是第一条指令,似乎存在某种“内存开销”,导致其非常缓慢。 将memset的时间调整到第二位,并将其他内容放在第一位,或者简单地将memset的时间调整为两次,可以使所有编译开关下memset最快!


谢谢你指出这一点。我最初在Atom上运行了测试,但现在只能访问PPC。至少在这台机器上,我可以确认你所说的。我猜测缓存正在发挥它的魔力。我想我现在得转向汇编语言了。 - maep

-1

memset函数可以被编译器优化为一系列高效的操作码,对于几个周期进行展开。对于非常大的内存块,例如4000x2000 64位帧缓冲区,您可以尝试通过多个线程(专门为此任务准备)来进行优化,每个线程设置自己的部分。请注意,还有bzero()函数,但它更加晦涩,不太可能像memset一样被优化,而且编译器肯定会注意到您传递了0。

编译器通常假设您使用memset函数来初始化大块内存,因此对于较小的内存块,如果您要初始化大量的小对象,则直接执行*(uint64_t*)p = 0可能更有效。

总的来说,所有的x86 CPU都是不同的(除非您为某个标准平台编译),并且在Pentium 2上进行优化的代码在Core Duo或i486上的表现会有所不同。因此,如果您真的想要挤出最后一点牙膏,为不同的流行CPU模型编译和优化您的可执行文件的几个版本是有意义的。从个人经验来看,使用Clang -march=native将我的游戏的FPS从60提高到了65,相比没有-march选项。


bzero在IEEE Std 1003.1-2008中已被移除。 - Polluks

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