代码改变世界

内存性能工具:Part 3 测量内存使用情况

2010-07-02 13:37  Robbin  阅读(2716)  评论(0编辑  收藏  举报

7.3 测量内存使用情况

了解程序分配了多少内存及是在哪里分配的内存,是优化内存使用的第一步。幸运的是,已经有这样一些易于使用的工具,甚至都不需要程序重编译或者进行特殊的修改。

第一个工具,massif,它可以从编译器自动生成的调试信息中提取足够的信息。它可以提供一个程序在一段时间内累计使用内存的概览。图7.7给出它生成的输出的示例。

图7.7: Massif输出

和cachegrind(7.2节)相似,massif也是一个使用valgrind架构的工具。像这样启动:

      valgrind --tool=massif command arg

command arg是要被测量的程序及其参数。程序将会被模拟执行且所有内存的分配都会被识别出来。每个内存分配操作都会被打上时间戳。新分配的内存大小会不断加入到整个程序分配的总值和某个调用分配到的总值。记录函数释放内存操作时也是一样的,释放的内存块的大小会从对应的统计和中减去。这些信息可以用于创建一幅图来显示随时间变化的程序内存使用状况,因为可以通过分配时的时间,来区分不同时刻分配的内存状况。massif会在内存终止前创建2个文件:massif.XXXXX.txt和massif.XXXXX.ps,其中XXXXX两种情况下都是指进程的PID。txt文件是一个对各种内存分配的调用的摘要,而ps文件则类似于图7.7。

Massif也可以记录程序栈(stack)的使用情况,用来判断应用全部的内存使用开销(footprint)的很有用。但很多时候是不可行的。某些情况下(一些线程栈或者使用了signalstack),valgrind运行时无法知道栈的使用限制。这种情况下,也没必要将栈的大小增加到统计值中。还有几种情况也是没必要的。如果程序会因此被影响,massif需要在启动时增加选项--stacks=no。注意,这是给valgrind的选项,因此必须放到要测量的程序名前面。

*译注:signal stack是GNU C库提供的一个功能,可以为信号处理器指定一块内存作为执行栈,更详细的可以查看http://www.gnu.org/s/libc/manual/html_node/Signal-Stack.html

许多程序提供自己的内存分配函数或者对系统的内存分配函数进行了包装。第一种情况,分配时通常不会被记录,第二种情况,使用的分配函数隐藏了真实情况,因为只有包装函数中的函数被记录了。因此,需要能够往内存分配函数列表中添加额外的函数。参数--alloc-fn=xmalloc可以指定函数xmalloc也是一个内存分配函数,通常GNU程序中它都是的。那么调用xmalloc会被记录,但xmalloc内部的调用是不会被记录的。

第二个工具是memusage;它属于GNU C库。它是一个简化版的massif(不过要比massif早很多就有了)。它只记录全部的堆(heap)使用情况(如果使用了-m选项,mmap调用也会被包含进去),可能也会包含栈的记录。可以将完整的内存使用或针对某个内存分配函数的所有记录的结果显示为图形。图形由memusage所带的脚本所创建,像valgrind一样,也必须像这样启动应用:

     memusage command arg

选项-p IMGFILE在要生成图形结果到IMGFILE时必须被指定,会存为一个PNG文件。和valgrind的模拟不同,它是通过在运行程序的过程中收集到数据。这意味着memusage要远比massif要快,在massif不适用时它也具有一定可用性。除了记者完整的内存消耗,它还会记录分配的内存大小,在程序终止时,会将所有的已分配内存大小的历史记录显示出来。这些信息会写到标准错误输出上。

有时可能无法或不适合直接调用想测量的程序。一个例子就是gcc的编译阶段(的子程序),它是由gcc驱动程序所启动的。那么这个阶段的的程序的名字需要通过memusage的-n NAME参数来指定。这也适用于当程序会启动其它程序的情况。如果没有程序名被指定,则所有程序都会被剖析。

两个程序,massif和memusage,都还有其它选项。程序员如果发现自己需要更多功能时,需要首先查询手册或帮助信息以确认这些附加功能是否已经实现。

现在我们已经了解了内存分配的信息应该如何获取,现在就很有必要知道如何通过这些信息理解内存和缓存的使用情况。高效的动态内存分配表现为连续的分配和压缩已使用的部分。这就回转到使预取(prefetch)更有效及减少缓存未命中(cache miss)。

一个需要读取不定数量的数据来进行后续处理的程序可以通过创建一个链表来实现这个目的,链表的每个元素包含一个数据项。这种分配方式的额外开销可能很小(单链表需要一个指针)但缓存会在访问数据时明显的影响性能。

又比如说,一个程序是无法保证顺序分配到的内存会顺序的排列在内存中的。这有很多可能的原因:

  • 在一个内存厚片(chunk)中,内存分配模块对小块(block)内存的分配实际是从后向前来分配的;
  • 一个内存厚片(chunk)耗尽,会从另外一个地址处的新块开始分配内存;
  • 分配不同大小的内存会从不同的内存池得到服务;
  • 多线程程序中不同线程会交叉申请内存。

如果数据必须在一开始就分配好已备后面使用,那么链表无疑是一个糟糕的主意。无法(甚至是不可能)保证链表中连续分配的元素在内存中也是相邻的。要想保证连续的分配结果,那么内存是不能从一个小的内存厚片中进行分配的。需要增加一个中间层来处理这个请求,它很容易被程序员所实现。另一种选择是使用GNC C库中的obstack实现。这种分配方式通常是顺序的,除非内存厚片被耗尽了,但这取决于请求分配的大小是多少,但通常很少见。Obstack并不是内存分配器的一个完善的替代品,对于释放对象它是有所限制的。更多的信息可以查看GNC C库的手册。

*译注:obstack是GNU C库提供的一个功能,可以作为任意“对象”的栈(stack),可以查看http://gcc.gnu.org/onlinedocs/libiberty/Obstacks.html

那么,如何从图中找出一个使用obstacks(或类似的技巧)是更好的选择呢?如果只是分析源码,可能无法找到合适的修改点,不过图形给出了一个寻找的途径。如果很多内存分配都是从同一个位置上分配得到的,那么这意味着从一个厚片(chunk)中进行分配可能有所改善。在图7.7中,我们可以看到一个可能的候选是在地址0x4c0e7d5上的分配。从第800ms到第1800ms直接的运行中,它是唯一(除了顶部绿色的 部分)增长的部分,并且上沿并不陡峭,这就说明它是由一系列相对较小的内存分配所组成的。这就表明这是一个使用obstacks或相似技巧的候选。

图形还可以显示何时总的内存分配较高。当图形不是按时间变化绘制而是按照不同类型的调用接口来显示(memusage默认就是这样)可能更直观。这种情况下,图中一个缓慢的凸起代表一组小的分配请求。memusage无法告诉说分配是在哪里发生的,不过通过比较massif的输出可以得到结果,或者程序员通过其它好的方法。大量的小分配请求应该合并以便达到连续的内存使用效果。

不过后者同时还有一个同样重要的情况:大量的分配也意味着有大量的管理数据开销(overhead)。就某一块来说可能不是一个问题。massif输出的图中标为"head-admin"的部分表示了开销部分,尽管它不大。不过,取决于malloc函数的实现,管理数据可能和数据库放在一起,在相同的内存区域中。当前的GNU C库的实现中是这样的:每个分配出去的块都有至少2个字节的头(32位系统有8字节,16字节有16字节)。除此以外,实际的块的大小通常会稍微大于所申请的大小,这取决于内存是如何管理的(将块大小向上取整到某个特殊值的倍数)。

这就意味着程序使用的内存与只被内存分配器所使用的用于管理之用的内存混在一起。我们可能会遇到下面这样的情况:

如果我们能够顺序处理内存块(可以最有效的利用预取功能),处理器会将所有内存头和填充字放到缓存中去,即使应用完全不会写或读这些信息。只有运行时回去使用内存头信息,但运行时也只会在内存块释放时才会如此。

每一个方块表示一个内存字,在这一小片内存区域中,有4个已分配内存块。内存头和填充部分的开销(overhead)达到50%。由于内存头的存在,这自然就意味着处理器有效的预取比只能到达50%左右。

现在,可能有人会说应该吧管理数据放到其它地方去。有的实现也确实这么做的,这可能也是个好主意。但还要注意,不能因此忽略安全。不管今后是否会有所改变,但填充部分是不会消失的(在例子中达到了16%,且忽略了头部信息)。只有当程序员直接控制分配时才能避免这种情况。而对齐的需求出现就仍会存在一些空洞,不过这也可以在程序员控制范围之内。