目前 kernel 在通用块层提供 on demand readahead 的算法框架。看字面意思,就是按需预读
我觉得大概分三个模块来描述整体框架比较合适
需要的数据结构
算法的入口
算法的实现
(注:下述算法描述是基于 Linux 4.18.20 实现)
readahead 状态
readahead 需要的数据结构位于 /include/linux/fs.h
/*
* Track a single file's readahead state
*/
struct file_ra_state {
pgoff_t start; /* where readahead started */
unsigned int size; /* # of readahead pages */
unsigned int async_size; /* do asynchronous readahead when
there are only # of pages ahead */
unsigned int ra_pages; /* Maximum readahead window */
unsigned int mmap_miss; /* Cache miss stat for mmap accesses */
loff_t prev_pos; /* Cache last read() position */
};
/*
* Set the initial window size, round to next power of 2 and square
* for small size, x 4 for medium, and x 2 for large
* for 128k (32 page) max ra
* 1-8 page = 32k initial, > 8 page = 128k initial
*/
static unsigned long get_init_ra_size(unsigned long size, unsigned long max)
{
unsigned long newsize = roundup_pow_of_two(size);
if (newsize <= max / 32)
newsize = newsize * 4;
else if (newsize <= max / 4)
newsize = newsize * 2;
else
newsize = max;
return newsize;
}
/*
* Get the previous window size, ramp it up, and
* return it as the new window size.
*/
static unsigned long get_next_ra_size(struct file_ra_state *ra,
unsigned long max)
{
unsigned long cur = ra->size;
unsigned long newsize;
if (cur < max / 16)
newsize = 4 * cur;
else
newsize = 2 * cur;
return min(newsize, max);
}
其设计目的是为了保证程序在任何时刻终止顺序访问都能得到可观的命中率
预读缓存命中
预读缓存命中是不必要的缓存命中行为,它的问题会减少 IO 的吞吐量
// ra_submit -> __do_page_cache_readahead
/*
* __do_page_cache_readahead() actually reads a chunk of disk. It allocates
* the pages first, then submits them for I/O. This avoids the very bad
* behaviour which would occur if page allocations are causing VM writeback.
* We really don't want to intermingle reads and writes like that.
*
* Returns the number of pages requested, or the maximum amount of I/O allowed.
*/
unsigned int __do_page_cache_readahead(struct address_space *mapping,
struct file *filp, pgoff_t offset, unsigned long nr_to_read,
unsigned long lookahead_size)
{
struct inode *inode = mapping->host;
struct page *page;
unsigned long end_index; /* The last page we want to read */
LIST_HEAD(page_pool);
int page_idx;
unsigned int nr_pages = 0;
loff_t isize = i_size_read(inode);
gfp_t gfp_mask = readahead_gfp_mask(mapping);
if (isize == 0)
goto out;
end_index = ((isize - 1) >> PAGE_SHIFT);
/*
* Preallocate as many pages as we will need.
*/
for (page_idx = 0; page_idx < nr_to_read; page_idx++) {
pgoff_t page_offset = offset + page_idx;
if (page_offset > end_index)
break;
rcu_read_lock();
page = radix_tree_lookup(&mapping->i_pages, page_offset);
rcu_read_unlock();
if (page && !radix_tree_exceptional_entry(page)) {
/*
* Page already present? Kick off the current batch of
* contiguous pages before continuing with the next
* batch.
*/
if (nr_pages)
read_pages(mapping, filp, &page_pool, nr_pages, // ⭐
gfp_mask);
nr_pages = 0;
continue;
}
page = __page_cache_alloc(gfp_mask);
if (!page)
break;
page->index = page_offset;
list_add(&page->lru, &page_pool);
if (page_idx == nr_to_read - lookahead_size)
SetPageReadahead(page); // ##flag #2
nr_pages++;
}
/*
* Now start the IO. We ignore I/O errors - if the page is not
* uptodate then the caller will launch readpage again, and
* will then handle the error.
*/
if (nr_pages)
read_pages(mapping, filp, &page_pool, nr_pages, gfp_mask);
BUG_ON(!list_empty(&page_pool));
out:
return nr_pages;
}
Linux 内核的 IO 预读算法
https://ift.tt/F2TthPB
性能优化的关键在于解决性能的瓶颈,而 IO 从来都是难以解决的瓶颈之一
这篇文章主要以算法作者的论文为基础,描述 Linux 内核对于读操作下的按需预读算法
基本的 IO 优化策略
在开始说预取算法前,还是要看一下常规的 IO 优化策略
然而,对于一个常规的应用来说,其 IO 行为是小且串行的:既无法异步,也无法并行,更无法提高吞吐量。针对这些行为,我们需要对数据进行预读取和延迟写(他们的关系并非正交,延迟写如 write back 等方法我们不独立讨论)。至于为什么需要,一个简单的原因就是通过预读取来满足上述 1、2、3 的优化方法
预读取的两种类型
预读取的方式可以分为:
通知式预读是知情的,需要用户配合,如
posix_fadvise
,不展开讨论;而启发式是完全用户透明的,其难点在于算法要求较高,也是后面讲介绍的具体内容两种启发式的预读取策略
除了 direct read,Linux 对于读接口会进行一定的预读策略,如:
mmap
为代表的readaround
read
为代表的readahead
它们的策略区别在于更加侧重于随机读还是顺序读(或者是通用的、任意类型的读操作)
可以以简单的形式来描述
mmap
过程中的readaround
:mmap
仅提供vma
和对应映射关系返回给用户态而
read-ahead
则更为复杂得多,也更加难以实现readahead 的复杂性
readahead
相比readaround
更加关注顺序读的性能,但也需要考虑多种 IO 负载下的情况,因为它位于VFS
下的generic read
层,read/pread/readv/sendfile
等接口都会统一到generic read
,要面临的情况也更为复杂,如:要实现
readahead
,就是要处理上述的复杂情况on demand readahead
目前 kernel 在通用块层提供 on demand readahead 的算法框架。看字面意思,就是按需预读
我觉得大概分三个模块来描述整体框架比较合适
(注:下述算法描述是基于 Linux 4.18.20 实现)
readahead 状态
readahead
需要的数据结构位于/include/linux/fs.h
需要关注的成员为
start
/size
/async_size
,它们通过 \(O(1)\) 的空间来维护ahead
窗口以作者的图来描述就是
start
和size
二元组构成预读窗口,记录最近一次预读请求的位置start
和连续大小size
,async_size
为预读提前量,表示还剩余async_size
个未访问页时启动下一次预读后面会讲述通过
ahead
窗口来实现一个 IO 流水线的操作通用层入口
以
read
/ext4
作为跟踪,经过其中
page_cache_sync_readahead
和page_cache_async_readahead
则为 readahead 入口可以看出,进入预读的条件有两种
PG_Readahead
简略流程
我尝试以简单的方式描述一次完全顺序执行读请求的预读过程。记
| =
为单个页,#
为被标记为PG_readahead
的页,^
为ra->start
,~
为offset
当前访问到的值当对一个没有访问过的文件/页进行顺序读时,且从
offset = 0, request_size = 1
(页为单位)开始,会触发同步预读由于没有访问过,
file_ra_state
(简记为ra
)中的四元组信息为预读流程会根据
prev_pos
构造出预读窗口,求出size
和async_size
,并且在offset = #
打上PG_readahead
标记其中
#
直到右边|
为async_size
范围,共读入size
大小的页假设下一次顺序读的起始页(
offset
)在^
后和#
前,则不触发预读继续顺序读至
offset = #
,则会发出下一次的异步预读由于这个过程中
~
是处于已预取状态的页,因此这次预读是完全 CPU 与 IO 异步的可以看出,预读窗口是这一次的预读请求,也是下一次预读时机的判断依据
而窗口的增长通常是倍增的形式,往后会按照没有页缓存或者触发标记页来继续执行上述预读
整体流程
PG_readahead
页,如果条件不允许,退出,否则走 3PG_readahead
,提交 IO可以看出,
read ahead
非常注重顺序读问题(处理步骤最多,也是最高的判断优先级)但同时也考虑通用读的情况代码集中在单文件
/mm/readahead.c
,但仍然太大了不方便贴出来,只展示核心部分实现细节
虽然总体框架看着简单(得益于算法作者的精妙设计),但仍然有很多细节值得琢磨:
其中问题 3 将直接影响问题 1、2 的解决策略
初始的窗口和迭代的窗口
先来看问题 3,窗口的更新策略。两个函数分别对应于
get_init_ra_size
/get_next_ra_size
,它们决定ra->size
的大小其设计目的是为了保证程序在任何时刻终止顺序访问都能得到可观的命中率
预读缓存命中
预读缓存命中是不必要的缓存命中行为,它的问题会减少 IO 的吞吐量
在提交 IO 的处理流程中,
read_pages
(即mapping->a_ops->readpages
)是需要连续的非缓存页,如果[page_idx, page_idx + nr_to_read)
的范围内存在页缓存,则需要执行多次分离的readpages
如果需要解决,那就要观察缓存页面的规律:多数的已缓存页面是连续相邻的。因此我们要检测出该缓存区域,并且禁止内部启动预读行为
按需算法对于预读缓存命中的解决策略有:
PG_readahead
,见##flag #2
PG_readahead
才执行预读(注:对于第一点,为了避免只因少量缓存存在导致的错误的拒绝打标记行为,按需算法可以更严格的执行只有全部页面都触发预读缓存命中才拒绝设置
PG_readahead
,但是算法中并没有用到这一严格策略)预读抖动
预读抖动是出于页面置换/回收机制的冲突。按需预读算法认为,预读抖动大概率发生在大规模并发的顺序访问负载中
因此它的检测机制判断如下:
当检测确认后,其预读抖动保护机制如下:
它的实现与非对齐行为合并在初始窗口流程,见
##flag #3
更为复杂的顺序读
在常见的应用场合中,顺序读也不是简单的线性增长
按需预读算法的解决策略十分简单:
PG_readahead
而进入预读例程,则往前判断一个页面缓存是否存在,若存在则证明这其实是一个空间上连续访问的流(时间上是交织的行为),不做重复预读处理,不存在则重新预读(注:交织读的处理方式其实就是为
PG_readahead
的标记方法寻找弥补方案)总结
本文大概梳理了预取算法中的核心流程,以及各种 corner case 下所设计的解决方案
预读算法的核心问题就是:如何为复杂场合设计一套简洁、高效而可扩展的算法
它的简洁和可扩展性在于整个算法流程是按流水线的形式来设计的,可读性极高(你可以直接看到任何处理方式的优先级),如果针对性地需要定制更为合适的解决方案,往流水线里插入额外的算法即可
另一个层面是算法上的优化,首先空间复杂度是仅有的
O(1)
,维护的变量少也意味着更加明确的状态转移,流水线参数其实就是async_size
;其次是时间复杂度上是尽可能的被动处理(有意思的是明明称为预读却从来都不积极),也通过简单的标记做法把调用预读例程的可能性降低,甚至微妙地避免了错误的状态转移,我觉得在工程意义上也有很大的借鉴价值更多
预读算法仍然有不少小细节可以挖掘,比如过大读的处理,或者根据历史缓存上下文找出潜在的顺序读等等。感兴趣的可以看算法作者吴峰光的论文 Linux 内核中的预取算法,以及一些相关的 commit message,随手 po 几个链接供参考:
差点忘了
按照惯例,系列文章要补上海猫鸣泣之时大侦探的图
via Caturra’s Blog
September 10, 2024 at 02:19PM