在Linux中清零页面的最快方法

6
我需要在Linux中清除大地址范围(每次大约200页)的内容。我尝试了两种方法:
  1. 使用memset - 这是最简单的清除地址范围的方式。执行速度比第二种方法稍慢。
  2. 使用munmap/mmap - 我在地址范围上调用了munmap然后再次使用相同的权限以及地址重新调用了mmap。由于传递了MAP_ANONYMOUS,因此页面被清除。
第二种方法使基准测试运行速度提高了5-10%。当然,基准测试执行了比仅清除页面更多的操作。如果我的理解是正确的,这是因为操作系统拥有零页面池,将其映射到地址范围中。
但我不喜欢这种方式,因为munmapmmap不是原子操作。换句话说,同时进行的另一次mmap(第一个参数为NULL)可能会使我的地址范围无法使用。
所以我的问题是,Linux是否提供了可以将零页面与地址范围中的物理页面交换的系统调用?
我尝试查看glibc源代码(特别是memset),以查看它们是否使用任何有效的技术来执行此操作。但是我什么都没找到。

1
也许:https://dev59.com/_YDba4cB1Zd3GeqPFo2k - Antti Haapala -- Слава Україні
3
注意:mmap()实际上不会清除内存。它只是从/dev/zero克隆页面,并设置COW。成本将在稍后产生,一旦(如果!)引用了这些页面。 - wildplasser
1
@wildplasser,这不是Ajay建议的内容 - 相反,如果它没有映射,那么很有可能在需要再次映射时,空闲进程已经将一些页面帧清零了... - Antti Haapala -- Слава Україні
1
@AndrewHenle,这就是我一个小时前说的话,但是楼主似乎并不真正理解克隆/dev/zero上的COW是什么意思。@OP:例如,请参见https://dev59.com/Xl7Va4cB1Zd3GeqPIUpk#8507066。 - wildplasser
1
@AjayBrahmakshatriya,您发布的代码不仅测量了memset()madvise()mmap()之间清除现有页面的时间差异,还计算了所有初始mmap()和最终munmaps()的时间,以及额外的循环来将'A'或'B'写入整个映射页面。这些都与性能差异无关,memset()madvise()mmap()之间的内存清零。顺便说一下,我运行的快速测试表明,简单的单线程调用memset()比重新执行mmap()要快五到十倍。 - Andrew Henle
显示剩余17条评论
3个回答

7

memset() 看起来比 mmap() 快大约一个数量级,至少在我目前能访问的 Solaris 11 服务器上是这样。我强烈怀疑 Linux 也会产生类似的结果。

我编写了一个小型基准测试程序:

#include <stdio.h>
#include <sys/mman.h>
#include <string.h>
#include <strings.h>

#include <sys/time.h>

#define NUM_BLOCKS ( 512 * 1024 )
#define BLOCKSIZE ( 4 * 1024 )

int main( int argc, char **argv )
{
    int ii;

    char *blocks[ NUM_BLOCKS ];

    hrtime_t start = gethrtime();

    for ( ii = 0; ii < NUM_BLOCKS; ii++ )
    {
        blocks[ ii ] = mmap( NULL, BLOCKSIZE,
            PROT_READ | PROT_WRITE,
            MAP_ANONYMOUS | MAP_PRIVATE, -1, 0 );
        // force the creation of the mapping
        blocks[ ii ][ ii % BLOCKSIZE ] = ii;
    }

    printf( "setup time:    %lf sec\n",
        ( gethrtime() - start ) / 1000000000.0 );

    for ( int jj = 0; jj < 4; jj++ )
    {
        start = gethrtime();

        for ( ii = 0; ii < NUM_BLOCKS; ii++ )
        {
            blocks[ ii ] = mmap( blocks[ ii ],
                BLOCKSIZE, PROT_READ | PROT_WRITE,
                MAP_FIXED | MAP_ANONYMOUS | MAP_PRIVATE, -1, 0 );
            blocks[ ii ][ ii % BLOCKSIZE ] = 0;
        }

        printf( "mmap() time:   %lf sec\n",
            ( gethrtime() - start ) / 1000000000.0 );
        start = gethrtime();

        for ( ii = 0; ii < NUM_BLOCKS; ii++ )
        {
            memset( blocks[ ii ], 0, BLOCKSIZE );
        }

        printf( "memset() time: %lf sec\n",
            ( gethrtime() - start ) / 1000000000.0 );
    }

    return( 0 );
}

请注意,在页面的任何位置写入一个字节就足以强制创建物理页面。
我在我的Solaris 11文件服务器上运行了它(目前只有这个原生运行POSIX样式系统)。我没有在我的Solaris系统上测试madvise(),因为与Linux不同,Solaris不能保证映射将重新填充零填充页,只能保证“系统开始释放资源”。
结果如下:
setup time:    11.144852 sec
mmap() time:   15.159650 sec
memset() time: 1.817739 sec
mmap() time:   15.029283 sec
memset() time: 1.788925 sec
mmap() time:   15.083473 sec
memset() time: 1.780283 sec
mmap() time:   15.201085 sec
memset() time: 1.771827 sec

memset() 几乎快了一个数量级。我有机会时,会在 Linux 上重新运行该基准测试,但很可能必须在虚拟机上运行(AWS 等)。

这并不令人惊讶 - mmap() 很昂贵,内核仍然需要在某个时候清零页面。

有趣的是,注释掉一行

        for ( ii = 0; ii < NUM_BLOCKS; ii++ )
        {
            blocks[ ii ] = mmap( blocks[ ii ],
                BLOCKSIZE, PROT_READ | PROT_WRITE,
                MAP_FIXED | MAP_ANONYMOUS | MAP_PRIVATE, -1, 0 );
            //blocks[ ii ][ ii % BLOCKSIZE ] = 0;
        }

产生以下结果:

setup time:    10.962788 sec
mmap() time:   7.524939 sec
memset() time: 10.418480 sec
mmap() time:   7.512086 sec
memset() time: 10.406675 sec
mmap() time:   7.457512 sec
memset() time: 10.296231 sec
mmap() time:   7.420942 sec
memset() time: 10.414861 sec

强制创建物理映射的负担已经转移到了memset()调用上,仅在测试循环中有隐式的munmap(),在这里,当MAP_FIXEDmmap()调用替换它们时,映射将被销毁。请注意,仅munmap()的时间比将页面保留在地址空间并将其memset()成零要长3-4倍。 mmap()的成本实际上不是mmap()/munmap()系统调用本身,而是新页面需要大量的后台CPU周期来创建实际的物理映射,这并不发生在mmap()系统调用本身 - 它发生在进程访问内存页面之后。
如果您对结果有疑问,请注意Linus Torvalds本人的LMKL帖子

...

然而,虚拟内存映射的游戏本身非常昂贵。它有许多相当真实的缺点,人们往往忽视它们,因为内存复制被认为是一件非常慢的事情,有时优化这种复制被视为一个明显的改进。

mmap 的缺点:

  • 设置和清除成本非常高昂。我的意思是“非常显著”。这些东西包括跟随页表以清理所有映射的工作。它是维护所有映射列表的簿记工作。它是取消映射后需要清除 TLB。
  • ...

使用Solaris Studio 的性能分析工具分析器工具对代码进行分析,产生了以下输出:

Source File: mm.c

Inclusive        Inclusive        Inclusive         
Total CPU Time   Sync Wait Time   Sync Wait Count   Name
sec.             sec.                               
                                                      1. #include <stdio.h>
                                                      2. #include <sys/mman.h>
                                                      3. #include <string.h>
                                                      4. #include <strings.h>
                                                      5. 
                                                      6. #include <sys/time.h>
                                                      7. 
                                                      8. #define NUM_BLOCKS ( 512 * 1024 )
                                                      9. #define BLOCKSIZE ( 4 * 1024 )
                                                     10. 
                                                     11. int main( int argc, char **argv )
                                                         <Function: main>
 0.011           0.               0                  12. {
                                                     13.     int ii;
                                                     14. 
                                                     15.     char *blocks[ NUM_BLOCKS ];
                                                     16. 
 0.              0.               0                  17.     hrtime_t start = gethrtime();
                                                     18. 
 0.129           0.               0                  19.     for ( ii = 0; ii < NUM_BLOCKS; ii++ )
                                                     20.     {
                                                     21.         blocks[ ii ] = mmap( NULL, BLOCKSIZE,
                                                     22.             PROT_READ | PROT_WRITE,
 3.874           0.               0                  23.             MAP_ANONYMOUS | MAP_PRIVATE, -1, 0 );
                                                     24.         // force the creation of the mapping
 7.928           0.               0                  25.         blocks[ ii ][ ii % BLOCKSIZE ] = ii;
                                                     26.     }
                                                     27. 
                                                     28.     printf( "setup time:    %lf sec\n",
 0.              0.               0                  29.         ( gethrtime() - start ) / 1000000000.0 );
                                                     30. 
 0.              0.               0                  31.     for ( int jj = 0; jj < 4; jj++ )
                                                     32.     {
 0.              0.               0                  33.         start = gethrtime();
                                                     34. 
 0.560           0.               0                  35.         for ( ii = 0; ii < NUM_BLOCKS; ii++ )
                                                     36.         {
                                                     37.             blocks[ ii ] = mmap( blocks[ ii ],
                                                     38.                 BLOCKSIZE, PROT_READ | PROT_WRITE,
33.432           0.               0                  39.                 MAP_FIXED | MAP_ANONYMOUS | MAP_PRIVATE, -1, 0 );
29.535           0.               0                  40.             blocks[ ii ][ ii % BLOCKSIZE ] = 0;
                                                     41.         }
                                                     42. 
                                                     43.         printf( "mmap() time:   %lf sec\n",
 0.              0.               0                  44.             ( gethrtime() - start ) / 1000000000.0 );
 0.              0.               0                  45.         start = gethrtime();
                                                     46. 
 0.101           0.               0                  47.         for ( ii = 0; ii < NUM_BLOCKS; ii++ )
                                                     48.         {
 7.362           0.               0                  49.             memset( blocks[ ii ], 0, BLOCKSIZE );
                                                     50.         }
                                                     51. 
                                                     52.         printf( "memset() time: %lf sec\n",
 0.              0.               0                  53.             ( gethrtime() - start ) / 1000000000.0 );
                                                     54.     }
                                                     55. 
 0.              0.               0                  56.     return( 0 );
 0.              0.               0                  57. }

                                                    Compile flags:  /opt/SUNWspro/bin/cc -g -m64  mm.c -W0,-xp.XAAjaAFbs71a00k.

请注意在每个新映射页面中设置单个字节所花费的大量时间,以及在mmap()中花费的大量时间。
这是来自分析工具的概述。请注意系统时间的大量使用:

Profile overview

大量消耗的系统时间是用于映射和取消映射物理页面的时间。
这个时间轴展示了所有这些时间被消耗的时间点。

enter image description here

淡绿色是系统时间-这都在循环中。当循环运行时,您可以看到切换到深绿色用户时间。我已经突出了其中一个实例,以便您在那个时间看到发生了什么。
来自Linux虚拟机的更新结果:
setup time:    2.567396 sec
mmap() time:   2.971756 sec
memset() time: 0.654947 sec
mmap() time:   3.149629 sec
memset() time: 0.658858 sec
mmap() time:   2.800389 sec
memset() time: 0.647367 sec
mmap() time:   2.915774 sec
memset() time: 0.646539 sec

这与我昨天在评论中所说的完全一致:顺便提一下,我进行的快速测试表明,对memset()进行简单的单线程调用比重新执行mmap()要快五到十倍 我根本不理解这种对mmap()的迷恋。 mmap()是一个非常昂贵的调用,并且它是一个强制的单线程操作 - 机器上只有一组物理内存。mmap()不仅,而且会影响整个进程地址空间和整个主机上的VM系统。
使用任何形式的mmap()来清零内存页面都是适得其反的。首先,页面不会免费清零 - 必须有memset()将它们清除。为了清除一页RAM,添加拆除和重新创建内存映射到那个memset()就毫无意义了。 memset()还具有这样的优点:可以同时清除多个线程的内存。更改内存映射是单线程过程。

首先,非常感谢您的详细分析,并特别对程序中的每个步骤进行了分析。我认为实际的mmap调用时间可以忽略不计,因为在我的情况下,就像我说的那样,我有大约200个连续页面。因此,只会有一个mmap调用。但是,将0设置到页面中确实需要很大的代价。我认为这是由于页面错误引起的(在memset中不会出现这种情况)。 - Ajay Brahmakshatriya
我在问题中可能应该提到,在执行memset之前,我必须先执行mprotect(因为我正在实现一个分配器)。我认为在这种情况下,仅进行单个mmap的成本将小于mprotect + memset。请注意,如果在mprotect之后执行memset,则memset的成本将很高(因为由于惰性分配,它可能还会有页面错误)。 - Ajay Brahmakshatriya
无论如何,这解决了我大部分的疑惑,通常情况下memsetmmap更快。但对于我的特定情况,因为我还需要更改权限(从PROT_NONEPROT_READ | PROT_WRITE),我认为单个调用mmap应该更好。 - Ajay Brahmakshatriya
2
@AjayBrahmakshatriya 实际mmap调用的时间可以忽略...你错了。在每个循环中分配/释放200页,这将折磨内存子系统。这将导致内核更新页面表、空闲列表和lru-cache的大量工作。此外:内核需要在这些资源列表上获取锁,这可能会影响其他进程。 - joop
1
无论如何,这解决了我大部分的疑惑,一般情况下memset比mmap更快。但对于我的特定情况,因为我还需要更改权限(从PROT_NONEPROT_READ | PROT_WRITE),我认为单个调用mmap应该效果更好。你为什么会认为在页面表项中翻转几个位比完全拆除和重做整个页面映射要慢?你有没有测试和基准测试这个假设? - Andrew Henle

2

madvise(..., MADV_DOTNEED) 应该等价于Linux上匿名映射的munmap/mmap。这有点奇怪,因为我不理解“不需要”的语义应该是什么样的,但它确实在Linux上丢弃了页面。

$ cat > foo.c
#include <sys/types.h>
#include <sys/mman.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>

int
main(int argc, char **argv)
{
    int *foo = mmap(NULL, getpagesize(), PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
    *foo = 42;
    printf("%d\n", *foo);
    madvise(foo, getpagesize(), MADV_DONTNEED);
    printf("%d\n", *foo);
    return 0;
}
$ cc -o foo foo.c && ./foo
42
0
$ uname -sr
Linux 3.10.0-693.11.6.el7.x86_64

MADV_DONTNEED 在其他操作系统上并不执行此操作,因此这并不具备可移植性。例如:

$ cc -o foo foo.c && ./foo
42
42
$ uname -sr
Darwin 17.5.0

但是,您不需要取消映射,只需覆盖映射即可。作为额外的奖励,这样做更加便携:

$ cat foo.c
#include <sys/types.h>
#include <sys/mman.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>

int
main(int argc, char **argv)
{
    int *foo = mmap(NULL, getpagesize(), PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
    *foo = 42;
    printf("%d\n", *foo);
    mmap(foo, getpagesize(), PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, -1, 0);
    printf("%d\n", *foo);
    return 0;
}
$ cc -o foo foo.c && ./foo
42
0
$

此外,我并不确定您是否正确地进行了基准测试。创建和删除映射可能非常昂贵,而且我认为空闲清零并不能帮助太多。新的内存映射文件(mmap):在首次使用之前实际上没有被映射,而在Linux上,这意味着写入而不是读取,因为如果对页面的第一次访问是读取而不是写入,则Linux会在写时复制零页面。因此,除非您对新的内存映射文件进行写入基准测试,否则我怀疑您之前的解决方案以及我在这里提出的解决方案都不会比简单的memset更快。

我之前不知道mmap会丢弃旧的映射。这似乎是一个更好的解决方案。但第二个mmap不应该失败吗?因为已经使用了MAP_FIXED并且该区域已经被占用了。 - Ajay Brahmakshatriya
如果直接 mmap 的行为已经定义好了,我就会使用它。我能否在第二个 mmap 中更改页面的权限?这样我就可以省去在分配器中调用 mprotect 的步骤。 - Ajay Brahmakshatriya
2
@AjayBrahmakshatriya 这基本上相当于创建一个全新的映射并替换以前存在的任何内容。因此,您可以更改权限。但是...在这样做之前,请阅读我编辑的最后一段,我不确定您是否正在对正确的事物进行基准测试。根据我对内存管理的了解(我在内核中的vm系统上工作了很多),munmap/mmap的基准测试比memset更快这一事实让我感到不对劲。 - Art
不仅如此,您可以使用多个线程来执行并行的memset()调用。但是,我建议的这些方法实际上都不会比简单的memset()更快。由于只有一个进程地址空间,因此无法进行并行的mmap()调用,因为它们必须被序列化。 - Andrew Henle
3
另一方面,我读过的最后一篇关于映射新页面成本与清零/复制成本的论文日期以“199”开头。然而,如果您的内核已经为Meltdown/Spectre打过补丁,我不可能看到每个页面都出现故障的效率比一个正确实现的memset更高。第四手,您可以尝试在Linux上使用MAP_POPULATE,这可能会奏效。第五手,依靠空闲循环清零会导致系统出现正反馈,性能在高负载下会降低,从而增加负载。 - Art
显示剩余8条评论

1
注意:这不是一个答案,我只需要格式化功能。

顺便说一句:可能 /dev/zero 的全零页面甚至不存在,而且 .read() 方法实现如下(类似的事情也发生在 dev/null 上,它只返回长度参数):


struct file_operations {
        ...
        ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
        ...
        };

static ssize_t zero_read (struct file *not_needed_here, char __user * buff, size_t len, loff_t * ignored)
{
memset (buff, 0, len);
return len;
}

我想我理解了我们的分歧所在。我想要补充的是,在我的基准测试中,我不仅包括munmap和mmap的时间,而且我还测量整个应用程序的时间。因此,就像你所说的那样,这些页面在写入时可能实际上被映射清除,我同意。我也在计算那段时间。 - Ajay Brahmakshatriya
首先给我们展示一些代码。我们不了解你的程序,也不知道你的测量/基准方法。 - wildplasser
这是一个小的测试例子。我的真正基准实际上是mmap的实现(mmap需要清零返回的虚拟地址),它在其上运行dlmalloc。这个dlmalloc实现被gcc使用。(来自spec_cpu_2006的gcc)。 - Ajay Brahmakshatriya
整个程序运行的时间被测量。 - Ajay Brahmakshatriya
你的测试程序在系统调用上浪费了太多的周期。(每个周期通过内存映射/解除映射) 我已经成功将在系统调用上花费的时间从5秒以上减少到不到0.5秒。 - wildplasser
哦,太好了。对于你来说,memset和mmap有什么区别?哪一个需要更多时间? - Ajay Brahmakshatriya

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