[原写于2012年8月]

(谨以此文感谢淘宝hadoop运维团队柯旻、刘毅、沈洪等同学对我的帮助和支持)

5月份和公司的hadoop团队一起研究在hadoop集群上的linux kernel优化,主要起因是hadoop集群测试发现rhel6的2.6.32内核跑hadoop应用比rhel5的2.6.18慢,测试方法是跑terasort。
通过blktrace查看2.6.32内核的io情况,发现一个奇怪的现象:明明系统的readahead大小设的是1MB,但是对hadoop中间文件file.out的每次readahead只读52K就停下来了。而在2.6.18内核上没有这个问题。对于像hadoop这样的“大读大写”的环境,readahead对性能的帮助很大,所以,如果readahead莫名其妙的停下来,对性能就是一种损伤。
52K,多奇怪,正好是13个4K的page,内核里有啥地方对13个page有特殊处理呢?....想不出来,于是漫长的debug开始了。

先看了一下readahead的实现,发现只要在预读过程中发现有一个page已经在内存中,就会停下来,也就是说,这个巨大的1.1G的file.out文件,每隔13个page就会有一个page在内存中,再说白一点:page1~page13不在内存中,page14在,page15~page27不在内存中,page28在....依此类推。
怎么会形成这个样子?一般来说,如果内存充足,一个文件在第一遍被读(或者被写)以后,对应的page会被放入lru链表里,且每个page会被置为REFERENCED;如果再读一遍这个文件,则page会被进一步置为ACTIVE。当系统内存不足时,OS会去回收cache里的page,哪些page先被回收呢?当然是只被读过一遍的那些REFERENCED的page了,因为它没有ACTIVE的page那么“热”。
从这个角度分析,hadoop里这个1.1G的file.out对应的page很可能有的是REFERENCED,有的是ACTIVE,不均匀,所以当系统回收page后,file.out有些page还留在内存,有些就不在了。发生这种情况有可能是有的进程反复读了file.out文件的某些部分,造成有些page热,有些page冷。问了hadoop团队的达人们,确实不存在这样的应用,hadoop基本就是顺序读顺序写,很规整的。也是,哪个应用会每隔13个page读一个page呢?而且,2.6.18跑同样的terasort不就没这个问题嘛,应该不是应用而是kernel的问题。

不得已,debug进入最艰苦的阶段:在kernel代码里加trace_printk一步步跟踪每个page的状态的变化。搞了3天,终于找到了原因:确实是2.6.32 kernel在把page置为REFERENCED和ACTIVE时逻辑不够严密。
我们在write时,实际的kernel代码走到了generic_perform_write(),里面会找到write对应的那个page,然后

write_begin
mark_page_accessed(page)
copy content into page
write_end

其中mark_page_accessed就是把page置为REFERENCED(对已经REFERENCED的置为ACTIVE)。
好,假设现在用户的write是每次写2K,那么对同一个page,会调用两次generic_perform_write():

write_begin
mark_page_accessed(page)
copy content into page
write_end
write_begin
mark_page_accessed(page)
copy content into page
write_end

看,这样写完一个文件后,每个page都被makr_page_accessed了两次,也就是说都成了ACTIVE。在2.6.18 kernel里,确实是这样;但在2.6.32 kernel里,不是这样!因为有人嫌每个page挨个儿进全局lru链表太麻烦了,改为用一个pagevec先把page存着(这个pagevec是放在cpu的cacheline里的,所以访问速度理论上来说比内存快),存满14个再一起放入全局lru链表。但是这个“优化”有漏洞:page先进的是pagevec而不是lru,结果mark_page_accessed就不再把它置为ACTIVE了:
void mark_page_accessed(struct page *page)
{
if (!PageActive(page) && !PageUnevictable(page) &&
PageReferenced(page) && PageLRU(page)) {
activate_page(page);
ClearPageReferenced(page);
} else if (!PageReferenced(page)) {
SetPageReferenced(page);
}
}
PageLRU(page)这时返回的是false,所以不会activate_page,结果前13个page虽然经历了两次mark_page_accessed,但都只是置为REFERENCED,而第14个page进pagevec时,pagevec就满了,这14个page都通通放入全局lru链表,这时再mark_page_accessed第14个page,第14个page就变成ACTIVE了!大家都是平等被访问,第14个page却总被置成”更热“。
fix这个问题的方案非常简单,就是不要让第14个page有特权,而是让它在被mark_page_accessed之前就一直呆在pagevec,别去全局lru链表。我已经发了一个patch到社区,响应还不错。
我们自己已经把这个补丁收入了淘宝kernel,在hadoop上再跑terasort,2.6.32终于快过了2.6.18内核,快了5%左右。
这个bug前后定位了两个星期。如果我们对kernel代码更熟悉一些,一看到14这个数字就想到pagevec是存14个page的,那问题定位就快多了。所以,还是要勤学多练啊 🙂

后记:

首先感谢大家对原文的关注,能和大家一起交流内核技术我非常的荣幸,这里试着回答大家的提问。

超然台上仙:有点不太理解请教一下, “看,这样写完一个文件后,每个page都被makr_page_accessed了两次,也就是说都成了AC TIVE。在2.6.18 kernel里,确实是这样;” 这说明这些page应该是active的,可是后面的修改, “fix这个问题的方案非常简单,就是不要让第14个page有特权,而是让它在被mark_page_a ccessed之前就一直呆在pagevec,别去全局lru链表。” 这样这些page不都成REFERENCED的了么

答:是的,按我的patch的方案,这14个page在被write了两遍以后,都是REFERENCED了。
可能有人要说,文件写了两遍,应该page都是ACTIVE呀。道理上说是这样,但是,pagevec的引入已经造成了“两遍写之后page都是REFERENCED”这个即成事实(当然,还有不均匀的问题),所以我第一次在社区发出这个patch的第一版时,就有人回信说干脆把pagevec去掉得了,但毕竟去pagevec牵扯太多,可能带来的副作用也不好评估,于是最后社区还是决定先收我这个patch,至少先把这14个page弄“平等”,大家都是REFERENCED总好过“前13个REFERENCED后一个ACTIVE“。
等以后哪位内核牛人决定了最终去掉pagevec,我这个patch也就没有存在的必要了。到了那个时候,再也没有不平等的page,而文件在连续的写过(或读过)两遍之后,page们就都是ACTIVE了。

zc19881024的来信:首先,2.6.18 kernel也同样使用了pagevec,__grab_cache_page中代码意思与32版本是一样的,似乎与文中描述不符
其次,patch修改顺序之后,按照文中的意思仍然没有解决前13个page只设置为REFERENCED的问题,这仍然有可能导致缓存替换时被换出。

答:zc19881024同学非常仔细,我去翻了一下code,发现在upstream的2.6.18内核里,generic_file_buffered_write的逻辑是:
__grab_cache_page()
prepare_write (对应2.6.32内核里的write_begin)
copy content into page
commit_write (对应2.6.32内核里的write_end)
mark_page_accessed(page)
在__grab_cache_page里确实是把page先放入了pagevec,所以,一样会出现page冷热不均的情况(我的patch里有复现这个问题的方法,大家可以试一试)。
但是,redhat的2.6.18是打了补丁的(我上文的2.6.18都是指redhat的),打过之后,__grab_cache_page变成了grab_cache_page_write_begin,这里面就没有用pagevec,而是直接加入全局lru链表了。很可能redhat已经发现了pagevec会带来副作用。
奇怪的是,redhat如果知道了pagevec引入的这个问题,为什么在新的rhel6里没有fix?....这个就不得而知了。
zc19881024的第二个问题如上回答。