熟悉block层多队列、nvme的小伙伴应该知道,nvme硬件多通道(或者称为多队列)的优势特别大。普通的ssd硬盘也只有一个通道进行IO数据传输,但是nvme却可以多个硬件队列同时IO读写,性能特别好。谈到这里想到一点,如果是单线程IO读写,只能发挥一个nvme硬件队列的优势,能不能发挥下nvme多个硬件队列的优势呢?
先介绍一个知识点,每个CPU都绑定一个唯一的nvme硬件队列,当然这些硬件队列可能会有重复的。我见过的一款性能不错的服务器,nvme1盘有80个硬件队列,这款服务器正好80核,故CPU0~CPU79分别绑定了不同nvme硬件队列,每个nvme硬件队列最多支持1023个IO请求。
曾经做过测试,在脏页回写这种重IO场景,iostat看到的io wait竟然可以达到20ms左右。block层多队列框架,向驱动发送IO请求的一个流程是:submit_bio…->blk_mq_make_request->blk_mq_try_issue_directly-> __blk_mq_try_issue_directly-> __blk_mq_issue_directly,这是把IO请求的相关数据发送给nvme磁盘驱动。然后就等该IO请求传输完成,产生中断执行blk_mq_complete_request->__blk_mq_complete_request->blk_mq_end_request->__blk_mq_end_request,统计IO使用率等数据,唤醒等待IO传输完成而休眠的进程等等。
nvme硬件队列传输IO数据是需要一定时间的,这段时间如果进程疯狂的执行submit_bio发送IO请求,进程所在CPU绑定的nvme硬件队列,只能在nvme驱动里累计这些IO请求。nvme驱动源码没研究过,但是按照经验它应该有一个队列(推测nvme硬件队列各有一个这样的队列),暂存submit_bio发送给nvme驱动的IO请求。测试过的服务器,IO繁忙时一个nvme硬件队列的驱动层队列最多会暂存1023个IO请求(有点像队列深度),这些IO请求只能排队等待传输完成,iostat监控此时io wait达到50ms左右。
因此得出一个结论,nvme也不是万能的,在IO繁忙时nvme硬件队列的驱动层队列暂存了很多IO请求时,照样io wait很高。针对这种情况,可以做优化吗?举个例子,cat test读取test文件,进程运行在CPU0,CPU0绑定的nvme硬件队列编号也是0,CPU1绑定的nvme硬件队列编号是1,其他类推。因为cat test读取过程nvme硬件队列0很繁忙,nvme硬件队列0驱动层队列暂存的IO请求达到1000多,正常情况肯定会io wait偏高。此时能否将cat test进程使用的nvme硬件队列0切换到nvme硬件队列1呢? cat test进程后续读取文件,使用的是新的空闲的nvme硬件队列1,不会再使用nvme硬件队列0,是不是就能降低IO延迟,降低io wait呢?
这就是本文的优化思路,这里称之为“nvme硬件队列繁忙时动态切换”。相关代码详细见GitHub - dongzhiyan-stack/kernel_brainstorming: 内核block层等优化,提升性能。在正式介绍前,需要说明一下,本文需要读者对内核block层多队列源码有个整体了解,建议读者先看一下我写的《linux内核block层Multi queue多队列核心点分析》这篇文章。
还是先从submit_bio…->blk_mq_make_request发送IO请求开始。按照内核block层多队列的要求,需要执行blk_mq_sched_get_request->__blk_mq_alloc_request->blk_mq_get_tag为bio分配一个tag。具体细节是,找到当前进程所在cpu,继而找到该cpu绑定的nvme硬件队列,nvme硬件队列用blk_mq_hw_ctx结构体表示。然后,从blk_mq_hw_ctx的成员struct blk_mq_tags *tags分配一个tag,测试的机器发现最多可以分配1023个tag。如果进程已经从nvme硬件队列分配了1023个tag,还要再分配的话,只能休眠等待谁释放tag。
接着,将bio转成request,执行blk_mq_try_issue_directly-> __blk_mq_try_issue_directly-> __blk_mq_issue_directly,把IO请求的相关数据发送给nvme磁盘驱动。等nvme磁盘驱动把该IO请求的数据传输完,产生中断,在中断服务函数里释放刚才从nvme硬件队列blk_mq_hw_ctx分配的tag,函数流程是blk_mq_complete_request->__blk_mq_complete_request->blk_mq_end_request->__blk_mq_end_request-> __blk_mq_end_request->blk_mq_free_request->blk_mq_sched_put_request->blk_mq_finish_request->blk_mq_finish_hctx_request->__blk_mq_finish_request->blk_mq_put_tag。
好的,我觉得nvme硬件队列的tag跟前文提到nvme硬件队列的驱动层队列深度的概念很接近,数量上限都是1023。进程IO传输时,需先为IO请求从nvme硬件队列分配一个tag,然后把IO请求发送到nvme硬件队列的驱动层队列,nvme驱动再从这个队列取出一个个IO请求传输数据,等传输完成释放掉刚才从nvme硬件队列分配的tag。
所以,可以简单的得出一个结论:nvme硬件队列被分配的tag越多,nvme硬件队列的驱动层队列中暂存的IO请求就越多,这个nvme硬件队列就越繁忙,越容器造成明显的io wait。本次的优化就是基于nvme硬件队列tag展开的,下边介绍具体是怎么修改代码的:
首先需要有一个变量能简单的表示nvme硬件队列已经分配的tag数,需在struct blk_mq_hw_ctx新增一个变量queue_transfer_reqs如下:
然后在进程从nvme硬件队列分配tag时令queue_transfer_reqs加1,在blk_mq_get_tag函数完成。代码如下,红色代码是新增的。
在nvme驱动将IO请求的数据传输完成,产生中断,在最后的回调函数blk_mq_put_tag()中释放从nvme硬件队列分配的tag,其实就是令nvme硬件队列的tag数queue_transfer_reqs减1,如下红色代码:
好的,准备工作完成,下来是重头戏:当进程IO读写,进程所在CPU绑定的nvme硬件队列被分配的tag数太多,即nvme硬件队列的驱动层队列暂存的IO请求数太多,则调整进程所在CPU的硬件队列,调整到一个空闲的nvme硬件队列,看下源码实现:
红色还是新增的代码,功能就是当前进程所在cpu绑定的nvme硬件队列被分配的tag数大于阀值queue_transfer_threshold(定义在下边),则判定该nvme硬件队列繁忙,需要为该进程找一个空闲的nvme硬件队列。queue_transfer_threshold初次测试时被赋值20,就是说,如果进程所在cpu绑定的nvme硬件队列被分配的tag数大于20,就要为这个进程找一个空闲的nvme硬件队列。需要说明一点queue_transfer_threshold这个nvme硬件队列繁忙阀值可以通过/sys文件系统echo动态设置。为这个进程找一个空闲的nvme硬件队列在make_queue_adjust()函数实现,如下(函数源码全是新增的):
注释写的比较清楚,就不再具体介绍了。需要提醒一点,blk_mq_get_driver_tag()函数也会从nvme硬件队列分配tag,故也需要把make_queue_adjust()把代码加到该函数里,这里不再列了,详细参考GitHub - dongzhiyan-stack/kernel_brainstorming: 内核block层等优化,提升性能。
好的,本文介绍的优化方法很简单,本质就是:IO传输时,如果进程所在CPU绑定的nvme硬件队列繁忙(繁忙标志是nvme硬件队列被分配的tag数太多或者nmve驱动队列暂存的IO请求太多),就为进程切换一个空闲的nvme硬件队列,达到降低IO延迟、降低io wait的效果。理论上分析应该是每什么问题的,但是实际测试时却遇到大问题,才算遇到了本次优化实践的最大挑战!
首先,为了能快速制造出nvme硬件队列繁忙场景,肯定得想办法使进程疯狂的IO读写,用fio或者dd效果不好。测试下来,用我写的《文件预读readahead内核优化提升文件读取性能》这篇文章介绍的方法,开启一个async_file_read内核线程,疯狂的读取文件。应用程测试代码也是这篇文章介绍的test_read.c。当然,你有其他方法也行,总之能达到进程疯狂IO读写而使nvme硬件队列繁忙的效果就行。
这里我把应用层测试代码test_read.c再列下
读取的文件test2大小是7.1G,在没有使用本文介绍的“nvme硬件队列繁忙时动态切换”优化方法时,就是正常的读文件,./test_read读取完test2文件耗时2.6s左右,iostat看到的io wait 50ms左右。测试多次都是这个数据,比较稳定!
在启用“nvme硬件队列繁忙时动态切换”优化后,./test_read读取完test2文件竟然耗时3.2s以上,一脸懵逼,没有提升IO性能反而拖累了文件读取效率呀!万事有果必有因,重点分析了blk_mq_sched_get_request()函数里添加的printk("%s %d %s cpu:%d hctx_queue_num:%d queue_transfer_reqs:%d\n",current->comm,current->pid,__func__,data->ctx->cpu,data->hctx->queue_num,atomic_read(&data->hctx->queue_transfer_reqs))调试信息,发现些许问题。
由于日志非常杂乱,这里不再贴原始日志,直接说结论:在./test_read 读取test2文件时,test_read进程运行在cpu0,确实因为cpu0绑定的nvme硬件队列0繁忙,把test_read进程使用的硬件队列0切换到了cpu1绑定的nvme硬件队列1。又因为nvme硬件队列1繁忙,又把test_read进程使用的nvme硬件队列1切换到了nvme硬件队列2……..,因为nvme硬件队列78繁忙,又把test_read进程使用nvme硬件队列切换到了nvme硬件队列79………..一共80个CPU,80个nvme硬件队列。大体是这样情况,实际的切换情况乱七八糟。
内核日志里可以大量看到,为test_read进程切换nvme硬件队列的日志
需注意,判定nvme硬件队列繁忙的标志是硬件队列已经分派的tag数是否大于queue_transfer_threshold变量。queue_transfer_threshold初值20,就是说,如果某个nvme硬件队列已经分配的tag数大于20,说明该nvme硬件队列的驱动层队列暂存的IO请求数大于20,则判定该nvme硬件队列繁忙。当然,queue_transfer_threshold可以通过echo 写/sys文件系统下某个文件,动态设置,最大值1023,最小值1。
内核还会看到这样的打印
第一行的打印,说明test_read进程运行的cpu0绑定的nvme硬件队列0的已经分配的tag数大于20了,该nvme硬件队列已经被判定繁忙了,但是接下来并没有打印make_queue_adjust cpu:0 switch to cpu:….,为什么呢?因为80个nvme硬件队列已经分配的tag数都大于20,所有的nvme硬件队列都繁忙,找不到一个空闲的nvme硬件队列了!
等test_read进程下次执行blk_mq_sched_get_request(),因为cpu1绑定的nvme硬件队列1的IO请求数据传输完成了,nvme硬件队列1释放了tag。即nvme硬件队列1已经被分配的tag数减1,才腾出空闲的tag。然后test_read进程运行把nvme硬件队列切换到了硬件队列1,因此打印make_queue_adjust cpu:0 switch to cpu:1。
继续测试,增大queue_transfer_threshold,100,500,800,1000,1022,1023,发现一个规律,queue_transfer_threshold越大,./test_read 读取test2文件耗时越接近最短耗时2.6s。并且,queue_transfer_threshold=1022时,./test_read 读取test2耗时2.7~2.8s;而queue_transfer_threshold=1023时,./test_read 读取test2耗时2.6s,原因是?
仔细分析后,当queue_transfer_threshold=1023,因为nvme硬件队列最多只有1023个空闲tag可分配,blk_mq_sched_get_request()函数里的if(atomic_read(&data->hctx->queue_transfer_reqs) > queue_transfer_threshold)永远不成立(hctx->queue_transfer_reqs最大1023),自然无法执行make_queue_adjust()函数切换nvme硬件队列。此时,相当于关闭了“nvme硬件队列繁忙时动态切换”优化功能,恢复了内核默认功能。真是可笑,queue_transfer_threshold=1023时读取test2文件耗时最短,竟然还是因为关闭了“nvme硬件队列繁忙时动态切换”功能!
为什么关闭“nvme硬件队列繁忙时动态切换”优化功能,反而使读取文件耗时最短,而开启了“nvme硬件队列繁忙时动态切换”优化功能,反而增大了文件读取耗时。深层次的原因是什么?这里得先分析下submit_bio…->blk_mq_make_request发送IO请求,在该函数里执行blk_mq_sched_get_request->__blk_mq_alloc_request->blk_mq_get_tag从进程所绑定的nvme硬件队列分配空闲tag。
把blk_mq_get_tag分配tag的源码再列下
执行tag = __blk_mq_get_tag(data, bt)就是查询nvme硬件队列是否还有空闲tag,如果已经被分配走了1023个tag,该函数就会返回-1,进程就会执行io_schedule()休眠。等当前nvme硬件队列的驱动层队列传输完成一个IO请求,产生中断并执行中断回调函数,唤醒前边在blk_mq_get_tag –>io_schedule()函数休眠的进程,最后释放该nvme硬件队列的tag。
这里需要提几点:
1 进程从执行blk_mq_get_tag –>io_schedule休眠到被唤醒,实际测试这段时间非常短,只有几百微妙级别,很奇怪。可能原因是,刚休眠就因为nvme硬件队列的驱动层队列暂存的IO请求传输完成了,很快产生中断唤醒这个进程。
2进程被唤醒后,按照实际调试结果大概率会被调度到新的cpu上运行。举个例子,关闭“nvme硬件队列繁忙时动态切换”功能下,前边test_read进程读取test2文件时,默认运行在cpu0,等在cpu0绑定的nvme硬件队列0分配了1023个tag,nvme硬件队列0的tag被掏空。然后test_read进程因为从nvme硬件队列0分配不到tag而执行blk_mq_get_tag –>io_schedule休眠,但是很快被唤醒。被唤醒后test_read进程被调度到了cpu1,这样test_read进程后续就使用nvme硬件队列1;继续,test_read进程把nvme硬件队列1的1023个tag分配完而休眠,被唤醒后调度到cpu2,接着使用nvme硬件队列2……….
就是说,test_read进程把前一个nvme硬件队列的空闲tag分配完,使该nvme硬件队列处于最繁忙的状态。然后因为从这个nvme硬件队列分配不到tag而休眠,等被唤醒后,test_read进程一般被调度到空闲的cpu,然后使用该cpu绑定的空闲nvme硬件队列。这样的话,test_read进程掏空一个nvme硬件队列,接着使用下一个空闲nvme队列,如此会把所有的nvme队列tag都分配掉。
分析下来,block层多队列默认框架好像已经有了“nvme硬件队列繁忙时动态切换”功能呀!似乎根本用不到本文介绍的“nvme硬件队列繁忙时动态切换”优化方案。故开启“nvme硬件队列繁忙时动态切换”功能时,性能并没有提升什么?
更深层次的原因是什么呢?就如前文所说,当“nvme硬件队列繁忙时动态切换”功能关闭,就是内核block层多队列代码没做任何修改。当进程疯狂IO读写,假设它最初运行在cpu0,以非常快的的速度把cpu0绑定的nvme硬件队列0的tag分配完,也就是使nvme硬件队列0的驱动队列被暂存的IO请求占满。然后进程无法再从nvme硬件队列0分配tag,无法向nvme硬件队列0的驱动队列继续派发IO请求,本质就是无法继续使用nvme硬件队里0进行IO传输!
然后进程被迫休眠,等nvme硬件队列0的驱动队列的有一个IO请求传输完成,产生中断,唤醒前边休眠的进程。进程被唤醒后被调度到了cpu1。进程开始使用cpu1绑定的nvme硬件队列1进行IO传输,这时nvme硬件队列0驱动队列暂存的IO请求一个个传输完成,在cpu0上产生中断,cpu0和cpu1完美的异步!然后进程把cpu1的nvme硬件队列1的tag分配完,也就是使nvme硬件队列1的驱动队列被暂存的IO请求占满。接着,进程被迫休眠,很快又唤醒,被调度到了cpu2,进程开始使用cpu2绑定的nvme硬件队里2进行IO传输,这段时间nvme硬件队列1的驱动层队列暂存的IO请求一个个传输完成,在cpu1上产生多次中断,cpu1和cpu2完美异步…….每个cpu之间这样循环。
简单举例总结:进程把前一个cpu绑定的nvme硬件队列IO能力掏空,本质是向该nvme硬件队列的驱动层队列发送的IO请求数达到上限(测试时是1023),不能再向该nvme硬件队列的驱动层队列发送的IO请求了。则进程被调度到下一个空闲cpu,接着把下一个cpu绑定的nvme硬件队列掏空(这段时间上一个cpu绑定的nvme硬件队列的驱动层队列的IO请求被一个个传输完成,然后上一个cpu产生一个个中断),再调度到下下一个空闲的nvme硬件队列……….循环,完美的异步!进程当前运行所在的cpu基本不会受nvme IO传输完成中断的影响。
而“nvme硬件队列繁忙时动态切换”功能开启时,进程始终只运行在一个cpu,无法调度到其他cpu。所有的IO请求传输完成产生中断都会在这个cpu上执行中断服务函数,影响了进程的运行,加大了不利的性能开销,毕竟这个方案只用到了一个cpu。而“nvme硬件队列繁忙时动态切换”功能关闭,是每个cpu都用到,即便进程休眠唤醒切换到空闲的cpu存在耗时,但这个时间很短。这是我分析“nvme硬件队列繁忙时动态切换”功能开启反而导致进程读写文件耗时增大的原因。
最初测试时是读取7G大小的文件,“nvme硬件队列繁忙时动态切换”功能开启读文件耗时反而比关闭多耗时几百毫秒。后续我有测试了读取100M、200M、500M、1G大小的文件,发现“nvme硬件队列繁忙时动态切换”功能开启后,读写文件并没有什么明显的性能提升,耗时跟关闭时差不多,甚至还高于关闭时的耗时。
主要有几点:
1 “nvme硬件队列繁忙时动态切换”的优化思路,打乱了nvme驱动原始CPU与nvme硬件队列的绑定关系,始终担忧会不会有什么隐患?
2“nvme硬件队列繁忙时动态切换”的优化思路,我始终觉得在读小文件时会发挥出效果的,或者场特殊景会发挥出效果。因为提前在nvme硬件队列接近繁忙时,切换到一个空闲的nvme队列,理论上是能提升性能的!还是后续慢慢研究吧。最后,在 blkio cgroup增加优化的代码,可以执行如下命令,令一个进程开启“nvme硬件队列繁忙时动态切换”功能,默认进程没这个功能。
具体源码请看GitHub - dongzhiyan-stack/kernel_brainstorming: 内核block层等优化,提升性能 的blk_mq_make_request()和tg_may_dispatch()函数以及struct cftype throtl_files结构体等。
3 使用nvme场景,进程IO读写时不要绑核,否则当进程所在cpu绑定的nvme硬件队列队列繁忙时,进程分配tag失败而休眠,再被唤醒,无法被调度到空闲的cpu,无法使用空闲cpu绑定的空闲nvme硬件队列,推测对性能不利。
以上是本文实际测试的心得总结,如果错误请指正。虽然这次对block层多队列的优化没达到预期效果,但还是学到很多东西的!