比mremap()更快的内存页面移动方法是什么?

20

我一直在尝试使用mremap()函数。我希望能够以更高的速度移动虚拟内存页,至少比复制它们要快。我有一些算法的想法,可以利用能够快速移动内存页的优势。问题是下面的程序显示,mremap()非常慢,至少在我的i7笔记本电脑上,与按字节复制相比,速度要慢得多。

测试源代码如何工作?mmap() 256 MB的RAM,这比CPU缓存要大。迭代200,000次。在每次迭代中,使用特定的交换方法交换两个随机内存页。运行一次并使用基于mremap()的页面交换方法计时。再次运行并使用按字节复制交换方法计时。结果表明,mremap()仅能够管理71,577个页面交换/秒,而按字节复制则可以管理287,879个页面交换/秒。因此,mremap()比按字节复制慢4倍!

问题:

mremap()为什么这么慢?

是否有其他用户空间或内核空间可调用的页面映射操作API可能更快?

是否有另一个用户空间或内核空间可调用的页面映射操作API,允许在一次调用中重新映射多个非连续页面?

是否有支持此类操作的内核扩展?

#include <stdio.h>
#include <string.h>
#define __USE_GNU
#include <unistd.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/errno.h>
#include <asm/ldt.h>
#include <asm/unistd.h>    

// gcc mremap.c && perl -MTime::HiRes -e '$t1=Time::HiRes::time;system(q[TEST_MREMAP=1 ./a.out]);$t2=Time::HiRes::time;printf qq[%u per second\n],(1/($t2-$t1))*200_000;'
// page size = 4096
// allocating 256 MB
// before 0x7f8e060bd000=0
// before 0x7f8e060be000=1
// before 0x7f8e160bd000
// after  0x7f8e060bd000=41
// after  0x7f8e060be000=228
// 71577 per second

// gcc mremap.c && perl -MTime::HiRes -e '$t1=Time::HiRes::time;system(q[TEST_COPY=1 ./a.out]);$t2=Time::HiRes::time;printf qq[%u per second\n],(1/($t2-$t1))*200_000;'
// page size = 4096
// allocating 256 MB
// before 0x7f1a9efa5000=0
// before 0x7f1a9efa6000=1
// before 0x7f1aaefa5000
// sizeof(i)=8
// after  0x7f1a9efa5000=41
// after  0x7f1a9efa6000=228
// 287879 per second

// gcc mremap.c && perl -MTime::HiRes -e '$t1=Time::HiRes::time;system(q[TEST_MEMCPY=1 ./a.out]);$t2=Time::HiRes::time;printf qq[%u per second\n],(1/($t2-$t1))*200_000;'
// page size = 4096
// allocating 256 MB
// before 0x7faf7c979000=0
// before 0x7faf7c97a000=1
// before 0x7faf8c979000
// sizeof(i)=8
// after  0x7faf7c979000=41
// after  0x7faf7c97a000=228
// 441911 per second

/*
 * Algorithm:
 * - Allocate 256 MB of memory
 * - loop 200,000 times
 *   - swap a random 4k block for a random 4k block
 * Run the test twice; once for swapping using page table, once for swapping using CPU copying!
 */

#define PAGES (1024*64)

int main() {
    int PAGE_SIZE = getpagesize();
    char* m = NULL;
    unsigned char* p[PAGES];
    void* t;

    printf("page size = %d\n", PAGE_SIZE);

    printf("allocating %u MB\n", PAGE_SIZE*PAGES / 1024 / 1024);
    m = (char*)mmap(0, PAGE_SIZE*(1+PAGES), PROT_READ | PROT_WRITE, MAP_SHARED  | MAP_ANONYMOUS, -1, 0);
    t = &m[PAGES*PAGE_SIZE];
    {
        unsigned long i;
        for (i=0; i<PAGES; i++) {
            p[i] = &m[i*PAGE_SIZE];
            memset(p[i], i & 255, PAGE_SIZE);
        }
    }

    printf("before %p=%u\n", p[0], p[0][0]);
    printf("before %p=%u\n", p[1], p[1][0]);
    printf("before %p\n", t);

    if (getenv("TEST_MREMAP")) {
        unsigned i;
        for (i=0; i<200001; i++) {
            unsigned p1 = random() % PAGES;
            unsigned p2 = random() % PAGES;
    //      mremap(void *old_address, size_t old_size, size_t new_size,int flags, /* void *new_address */);
            mremap(p[p2], PAGE_SIZE, PAGE_SIZE, MREMAP_FIXED | MREMAP_MAYMOVE, t    );
            mremap(p[p1], PAGE_SIZE, PAGE_SIZE, MREMAP_FIXED | MREMAP_MAYMOVE, p[p2]);
            mremap(t    , PAGE_SIZE, PAGE_SIZE, MREMAP_FIXED | MREMAP_MAYMOVE, p[p1]); // p3 no longer exists after this!
        } /* for() */
    }
    else if (getenv("TEST_MEMCPY")) {
        unsigned long * pu[PAGES];
        unsigned long   i;
        for (i=0; i<PAGES; i++) {
            pu[i] = (unsigned long *)p[i];
        }
        printf("sizeof(i)=%lu\n", sizeof(i));
        for (i=0; i<200001; i++) {
            unsigned p1 = random() % PAGES;
            unsigned p2 = random() % PAGES;
            unsigned long * pa = pu[p1];
            unsigned long * pb = pu[p2];
            unsigned char t[PAGE_SIZE];
            //memcpy(void *dest, const void *src, size_t n);
            memcpy(t , pb, PAGE_SIZE);
            memcpy(pb, pa, PAGE_SIZE);
            memcpy(pa, t , PAGE_SIZE);
        } /* for() */
    }
    else if (getenv("TEST_MODIFY_LDT")) {
        unsigned long * pu[PAGES];
        unsigned long   i;
        for (i=0; i<PAGES; i++) {
            pu[i] = (unsigned long *)p[i];
        }
        printf("sizeof(i)=%lu\n", sizeof(i));
        // int modify_ldt(int func, void *ptr, unsigned long bytecount);
        // 
        // modify_ldt(int func, void *ptr, unsigned long bytecount);
        // modify_ldt() reads or writes the local descriptor table (ldt) for a process. The ldt is a per-process memory management table used by the i386 processor. For more information on this table, see an Intel 386 processor handbook.
        // 
        // When func is 0, modify_ldt() reads the ldt into the memory pointed to by ptr. The number of bytes read is the smaller of bytecount and the actual size of the ldt.
        // 
        // When func is 1, modify_ldt() modifies one ldt entry. ptr points to a user_desc structure and bytecount must equal the size of this structure.
        // 
        // The user_desc structure is defined in <asm/ldt.h> as:
        // 
        // struct user_desc {
        //     unsigned int  entry_number;
        //     unsigned long base_addr;
        //     unsigned int  limit;
        //     unsigned int  seg_32bit:1;
        //     unsigned int  contents:2;
        //     unsigned int  read_exec_only:1;
        //     unsigned int  limit_in_pages:1;
        //     unsigned int  seg_not_present:1;
        //     unsigned int  useable:1;
        // };
        //
        // On success, modify_ldt() returns either the actual number of bytes read (for reading) or 0 (for writing). On failure, modify_ldt() returns -1 and sets errno to indicate the error.
        unsigned char ptr[20000];
        int result;
        result = modify_ldt(0, &ptr[0], sizeof(ptr)); printf("result=%d, errno=%u\n", result, errno);
        result = syscall(__NR_modify_ldt, 0, &ptr[0], sizeof(ptr)); printf("result=%d, errno=%u\n", result, errno);
        // todo: how to get these calls returning a non-zero value?
    }
    else {
        unsigned long * pu[PAGES];
        unsigned long   i;
        for (i=0; i<PAGES; i++) {
            pu[i] = (unsigned long *)p[i];
        }
        printf("sizeof(i)=%lu\n", sizeof(i));
        for (i=0; i<200001; i++) {
            unsigned long j;
            unsigned p1 = random() % PAGES;
            unsigned p2 = random() % PAGES;
            unsigned long * pa = pu[p1];
            unsigned long * pb = pu[p2];
            unsigned long t;
            for (j=0; j<(4096/8/8); j++) {
                t = *pa; *pa ++ = *pb; *pb ++ = t;
                t = *pa; *pa ++ = *pb; *pb ++ = t;
                t = *pa; *pa ++ = *pb; *pb ++ = t;
                t = *pa; *pa ++ = *pb; *pb ++ = t;
                t = *pa; *pa ++ = *pb; *pb ++ = t;
                t = *pa; *pa ++ = *pb; *pb ++ = t;
                t = *pa; *pa ++ = *pb; *pb ++ = t;
                t = *pa; *pa ++ = *pb; *pb ++ = t;
            }
        } /* for() */
    }

    printf("after  %p=%u\n", p[0], p[0][0]);
    printf("after  %p=%u\n", p[1], p[1][0]);
    return 0;
}

更新:为了不必质疑“往返内核空间速度”有多快,这里有一个进一步的性能测试程序,它显示我们可以在同一台i7笔记本电脑上连续调用getpid() 3次,每秒81,916,192次。
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

// gcc getpid.c && perl -MTime::HiRes -e '$t1=Time::HiRes::time;system(q[TEST_COPY=1 ./a.out]);$t2=Time::HiRes::time;printf qq[%u per second\n],(1/($t2-$t1))*100_000_000;'
// running_total=8545800085458
// 81916192 per second

/*
 * Algorithm:
 * - Call getpid() 100 million times.
 */

int main() {
    unsigned i;
    unsigned long running_total = 0;
    for (i=0; i<100000001; i++) {
        /*      123123123 */
        running_total += getpid();
        running_total += getpid();
        running_total += getpid();
    } /* for() */
    printf("running_total=%lu\n", running_total);
}

更新2:我添加了一个调用我发现的名为modify_ldt()的函数的WIP代码。手册暗示可能可以进行页面操作。然而,无论我尝试什么,当我期望它返回读取的字节数时,该函数总是返回零。'man modify_ldt'说:“成功时,modify_ldt()返回实际读取的字节数(对于读取)或0(对于写入)。失败时,modify_ldt()返回-1并设置errno以指示错误。”有什么想法吗(a)是否modify_ldt()将是mremap()的替代品?以及(b)如何使modify_ldt()正常工作?


man mremap - mremap() 可以扩展(或缩小)现有的内存映射。您的使用情况是重定位现有的映射。虽然可以这样做,但这并不是它的原始目的; 基本上就像 R 所述。 - artless noise
man mremap 还提到:“mremap() 使用 Linux 的页表方案。mremap() 可以改变虚拟地址和内存页面之间的映射关系,可以用于实现非常高效的 realloc(3)。”因此,“非常高效的realloc”是否基于上述发现有些不准确呢? - simonhf
realloc() 的使用场景是在当前分配的内存末尾追加内存。你上面的测试代码是替换中间的分配。通常不会将所有分配都放在一起,然后尝试重新排序它们;它们保证不适合。仔细看看t;甚至不需要分配它,只需将地址用作备用地址空间。即,m = mmap(0, PAGE_SIZE*(PAGES)...,而且 memset() 不需要吗?mremap 在增长时是否初始化内存... - artless noise
请检查mremap()的返回值。我认为它没有将内存放置在你请求的地址处。它正在进行比你想象的更多的操作。 - artless noise
TLB失效在多核CPU上至少会花费1000个周期。对于单个页面,当您加入系统调用、锁定、页表更改和TLB无效时,您将完全进入“那太糟糕了”的领域。但是,mremap对于大内存区域的重新分配非常棒。另外,如果您不必在现有映射的中间重新映射页面,则可能需要较少的页表锁定(无需拆分现有的VMA条目,更改树本身通常需要比更改条目更多的锁定)。 - Eloff
3
“getpid”不是系统调用的一个很好的测试案例。实现C API的大部分(如果不是全部)运行时库在第一次调用后会缓存系统调用的结果(毕竟该结果永远不会改变),因此所有随后的调用只是从缓存中读取结果,而不是进行系统调用。例如,对于glibc 2.3.4及更高版本而言,这是正确的;其他运行时库也可能采用这种明显的优化方式。 - ShadowRanger
2个回答

22

看起来没有比 memcpy() 更快速的用户空间机制来重新排序内存页面。mremap() 的速度要慢得多,因此仅适用于重新调整使用 mmap() 分配的内存区域。

但是,页表必须非常快速!而且可以让用户空间每秒调用数百万次内核函数!以下参考资料有助于解释为什么 mremap() 如此缓慢:

“Intel 内存管理简介” 是一份关于内存页面映射理论的好材料。

“Intel 虚拟内存的关键概念” 显示了更详细的所有工作原理,以防您计划编写自己的操作系统 :-)

“在 Linux 内核中共享页表” 显示了一些困难的 Linux 内存页面映射体系结构决策及其对性能的影响。

综合这三个参考资料,我们可以看出,迄今为止,内核架构师很少开发出一种有效的方式将内存页面映射暴露给用户空间。即使在内核中,对页表的操作也必须使用最多三个锁来完成,这将会很慢。

展望未来,由于页表本身由 4k 页组成,因此可能可以更改内核,使特定页表页唯一与特定线程相关,并可以在进程的持续时间内假定具有无锁访问。这将通过用户空间实现对该特定页表页的非常高效的操作。但这超出了原始问题的范围。


2
您应该能够使用SSE流处理内置函数,这可能比memcpy更快。您的内存块是4kb对齐的,因此您可以轻松使用SSE / AVX /等来读写内存,并且流内置函数将避免污染缓存(取决于内存类型,WC / WB /等和您拥有的硬件)。请参见_mm_stream_load_si128。您还可以轻松展开、预取和填充TLB。 - Nicholas Frechette
第三个已过期的链接更新为:https://landley.net/kdocs/ols/2003/ols2003-pages-315-320.pdf - Tomáš Janoušek

13
你认为 mremap 用于交换单个4k页面会有效吗?至少,即使只是读取单个值(如pid)并返回它,往返内核空间的代价也比移动4k的数据要高。而且这还没有考虑重新映射内存的缓存失效/TLB成本,我对此不太了解,无法在本答案中进行解释,但应该会产生一些严重的代价。 mremap 只适用于基本上一件事情:为了实现realloc用于大型的分配,这些分配由mmap提供服务。而且所谓的大型,我指的是可能至少100k。

2
谢谢您的回答,但是您认为“往返内核空间”如此缓慢,比移动4k数据更慢的原因是什么?我已经添加了一个进一步的性能测试程序,证明“往返内核空间”不应该是问题。我们可以每秒进行8200万次对getpid()的3次调用,但每秒只交换0.2万个4k块。 - simonhf
4
请注意,glibc在用户空间缓存getpid的结果。这是不幸的,事实上是错误的,因为在某些具有信号处理程序和fork的设置中可能会导致错误的结果。 - R.. GitHub STOP HELPING ICE
1
是的,说得好。当我使用getuid()重新运行getpid()性能测试时,每秒82百万次变成了每秒8,092,532次3个getuid()调用。所以慢了10倍,但仍比使用逐字节复制交换两个4k块快40倍。 - simonhf
2
如果你真的想尝试让你的想法实现,我认为你将不得不发明一个新的系统调用(或者是mremap的新标志)来在单个系统调用中交换页面。但它仍然需要大量的非平凡工作来拆分和合并VMAs,因为内核不会单独考虑页面;它将它们视为VMA跨度的一部分。也许有一种方法,如果它是匿名内存,你可以只切换它所支持的底层页面而不调整VMAs,但为了使其工作,你几乎肯定需要提前锁定所有内存(mlock)。 - R.. GitHub STOP HELPING ICE
3
我发现了一篇论文《在Linux内核中共享页面表》,这篇论文内容非常有用,介绍了remap_file_pages()函数,但我发现它比mremap()函数还要慢。论文详细解释了为什么mremap()函数很慢(由于多个锁的原因),还阐述了内核开发人员在实现虚拟内存方面所面临的艰难选择。最有趣的引用是:“大规模共享应用程序可能使用超过物理内存一半的页表。”[1] http://www.linuxinsight.com/files/ols2003/mccracken-reprint.pdf - simonhf
显示剩余6条评论

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