当前位置: 首页 > 科技观察

开放IO栈:一次编译服务器性能优化

时间:2023-03-20 16:48:14 科技观察

背景随着企业级SDK在多个产品线的广泛使用和SDK开发者的壮大,提交给SDK的补丁数量与日俱增,自动提交代码检查的压力显然已经超过了一般的服务器负载。所以我向公司申请了专用服务器,用于SDK构建检查。$cat/proc/cpuinfo|grep^proccessor|wc-l48$free-htotalusedfreesharedbufferscachedMem:47G45G1.6G20M7.7G25G-/+buffers/cache:12G35GSwap:0B0B0B$df文件系统可用容量已用%挂载点....../dev/sda198G14G81G15%//dev/vda12.9T1.8T986G65%/home这是一个KVM虚拟服务器,提供48个CPU线程,47G可用内存,约3TB磁盘空间。由于服务器的所有资源都是独享的,设置了十多个worker并行编译,从提交补丁到发送编译结果的速度很快。但是当补丁提交很多的时候,速度瞬间变慢,一次提交触发的编译甚至需要一个多小时。通过top可以看出cpu负载不高。是IO瓶颈吗?找IT获取root权限,搞定!由于认知的局限,如果有什么地方没有想好,希望大家多多交流,共同学习对IO栈的整体理解。如果有完整的IO栈的了解,无疑有助于更精细地优化IO。按照IO栈从上到下的顺序,我们逐层分析可以优化的地方。网上有完整的LinuxIO栈结构图,但是过于完整,难以理解。按照我的理解,简化后的IO栈应该如下图所示。用户空间:除了用户自己的APP,所有的库也是隐含的,比如常见的C库。我们常用的IO函数,比如open()/read()/write()都是系统调用,直接由内核实现,而fopen()/fread()/fwrite()则是C库实现的函数.封装系统调用以实现更高级的功能。虚拟文件系统:屏蔽特定文件系统的差异,为用户空间提供统一的入口。具体文件系统通过register_filesystem()向虚拟文件系统注册挂载钩子,当用户挂载具体文件系统时,通过回调挂载钩子来初始化文件系统。虚拟文件系统提供inode记录文件的元数据,dentry记录目录项。对于用户空间,虚拟文件??系统注册系统调用,例如SYSCALL_DEFINE3(open,constchar__user*,filename,int,flags,umode_t,mode)注册open()系统调用。具体文件系统:文件系统需要实现存储空间的管理。也就是说,它规划了哪个空间存放了哪些文件的数据,就像每个储物箱一样,A文件存放在这一块,B文件放在哪一块。不同的管理策略和它们提供的不同功能创建了各种各样的文件系统。除了常见的vfat、ext4、btrfs等块设备文件系统外,还有sysfs、procfs、pstorefs、tempfs等基于内存的文件系统,以及yaffs、ubifs等基于Flash的文件系统。页缓存:可以简单理解为一块存储磁盘数据的内存,但里面是以页为管理单位的,常见的页大小为4K。这块内存的大小不是固定的,每有一条新的数据,就申请一个新的内存页。由于内存的性能远大于磁盘,为了提高IO性能,我们可以将IO数据缓存在内存中,这样就可以在内存中获取需要的数据,而无需长时间的读取等待并从磁盘写入。申请内存缓存数据很简单,但是如何管理所有的页面缓存,如何及时回收缓存的页面才是本质。通用块层:通用块层还可以细分为bio层和request层。pagecache以页为管理单位,bio记录了磁盘块与页的关系。一个磁盘块可以关联多个不同的内存页,bio通过submit_bio()提交给请求层。一个request可以理解为多个bios的集合,将地址连续的多个bios组合成一个request。多个请求通过IO调度算法进行合并排序,有序的将IO请求提交给下层。设备驱动和块设备:不同的块设备有不同的使用协议,具体的设备驱动实现了具体设备所需要的协议,才能正常驱动该设备。对于块设备,块设备驱动程序需要将请求逐条解析成设备操作指令,并在协议规范下与块设备进行通信,交换数据。形象地讲,发起一个IO读请求的过程是怎样的呢?用户空间通过虚拟文件系统提供的统一IO系统调用从用户态切换到内核态。虚拟文件系统通过调用特定文件系统注册的回调将需求传递给特定文件系统。然后具体的文件系统根据自己的管理逻辑转换为具体的磁盘块地址,从页缓存中查找块设备的缓存数据。读取操作通常是同步的。如果页面缓存中没有缓存数据,则向通用块层发起磁盘读取。通用块层对所有进程产生的IO请求进行归并排序,通过设备驱动从块设备中读取真正的数据。最后逐层返回。读取到的数据不仅会复制到用户空间的buffer中,还会在pagecache中保留一份,以便下次快速访问。如果pagecachemiss,同步会一直到blockdevice,对于异步写,数据放入pagecache并返回,kernelrefreshprocess会在合适的时候闪回blockdevice时间。按照这个流程,考虑到我对KVM主机没有权限,只能开始优化Guest端的IO栈,包括以下几个方面:交换分区(swap)文件系统(ext4)页面缓存(PageCache)Request层(IO调度算法)对随机IO的要求非常高,因为源码和编译后的临时文件不大但是数量却非常多。为了提高随机IO的性能,需要在不改变硬件的情况下,缓存更多的数据来合并更多的IO请求。咨询了ITer,得知服务器有后备电源,保证不会因为停电而关机。在这种情况下,我们可以尽可能优化速度,而不用担心断电导致数据丢失。总的来说,优化的核心思想是尽可能多的使用内存来缓存数据,尽可能减少不必要的开销,比如文件系统使用日志来保证数据一致性带来的开销。交换分区交换分区的存在可以让内核在内存压力大的时候,将一些不常用的内存替换到交换分区中,从而为系统腾出更多的内存。当物理内存容量不足,运行着耗内存的应用程序时,swap分区的作用就非常明显。但是,本次优化的服务器应该不会使用swap分区。为什么?服务器总内存达到47G,服务器除了Jenkinsslave进程外没有很多耗内存的进程。从内存使用的角度来看,大部分内存被cache/buffer占用,是可丢弃的文件缓存,所以内存是足够的,不需要通过swap分区扩展虚拟内存。#free-htotalusedfreesharedbufferscachedMem:47G45G1.6G21M18G16G-/+buffers/cache:10G36G交换分区也是磁盘的空间。从swap分区插入和导出数据也会占用IO资源,与本次IO优化的目的相违背,所以这里在服务器中,需要取消swap分区。检查系统状态显示此服务器上未启用交换。#cat/proc/swapsFilenameTypeSizeUsedPriority#文件系统用户发起一次读写,经过虚拟文件系统(VFS)后,交给实际文件系统。首先查询分区挂载状态:#mount.../dev/sda1onon/typeext4(rw)/dev/vda1on/hometypeext4(rw)...这个服务器主要有两个块设备,分别是sda和vda。sda是一种常见的SCSI/IDE设备。如果我们个人PC上使用的是机械硬盘,往往会是sda设备节点。vda是一个virtio磁盘设备。由于这个服务器是KVM提供的虚拟机,所以sda和vda其实都是虚拟设备。不同的是前者是全虚拟化的块设备,后者是半虚拟化的块设备。从网上查到的资料来看,使用半虚拟化设备可以实现Host和Guest之间更高效的协作,从而获得更高的性能。本例中使用sda作为根文件系统,vda用于存储用户数据。编译的时候主要看vda分区的IO情况。vda使用ext4文件系统。ext4是目前linux上常用的稳定文件系统,查看其超级块信息:#dumpe2fs/dev/vda1...Filesystemfeatures:has_journaldir_index...inodecount:196608000Blockcount:786431991Freeinodes:145220571Blocksize:4096...我猜分区由ITer使用的默认参数格式化,块大小为4K,inode为1.966亿,并启用了日志。将blocksize设置为4K没有错,适合当前源文件太小的情况,没必要为了更紧凑的空间而减小blocksize。空闲inode达到14522万个,空闲率达到73.86%。目前74%的空间使用率,inode只使用了26.14%。一个inode占用256B,那么一亿个inode占用23.84G。inode太多,导致空间浪费很多。不幸的是,inode的个数是在格式化的时候指定的,以后不能修改,目前也不能简单粗暴的重新格式化。我们可以做什么?我们可以从log和mount参数入手,对日志进行优化,保证断电时文件系统的一致性,(在有序日志模式下)通过将元数据写入日志块,写入数据后再次修改元数据。如果此时掉电,可以通过日志记录将文件系统回滚到上次一致的状态,即保证元数据和数据匹配。不过上面说了,这个服务器有后备电源,不用担心停电,所以日志是可以完全取消的。#tune2fs-O^has_journal/dev/vda1tune2fs1.42.9(2014年2月4日)has_journal功能只能在文件系统卸载或以只读方式挂载时清除。可惜失败了。由于一直有任务在执行,直接umount或者-oremount、ro都不行,挂载的时候不能取消日志。既然取消不了,那我们就减少日志的丢失,需要修改挂载参数。ext4挂载参数:dataext4有三种日志模式,分别是ordered、writeback、journal。关于它们的区别网上有很多资料,我简单介绍一下:jorunal:将元数据和数据一起写入日志块。性能几乎减半,因为数据写了两次,但是最安全的回写:将元数据写入日志块,不将数据写入日志块,但不保证数据一定会写入磁盘第一的。性能最高,但由于不保证元数据和数据的顺序,所以也是掉电时最不安全的顺序。这样的性能足以保证足够的安全性。这是大多数PC上推荐的默认模式。在不需要担心断电的服务器环境中,我们可以使用回写的日志方式来获得最高的性能。#mount-oremount,rw,data=writeback/homemount:/homenotmountedorbadoption#dmesg[235737.532630]EXT4-fs(vda1):Cannotchangedatamodeonremount受挫,无法动态更改。干脆写入/etc/config,只希望下次Rebooted。#cat/etc/fstabUUID=.../homeext4defaults,rw,data=writeback...ext4挂载参数:noatimeLinux为每个文件记录3个时间戳。时间戳的全称意思是atimeaccess时间访问时间,最近一次读取的时间mtimedata修改时间数据修改时间是最后一次修改内容的时间ctimestatuschangetime文件状态(元数据)改变时间,比如权限,owner等。我们编译执行的Make可以根据修改时间Compilation判断是否重试,而atime记录的访问时间在很多场景下其实是多余的。因此,noatime应运而生。一次不记录可以大大减少读取带来的元数据写入量,而元数据的写入往往会产生大量的随机IO。#mount-o...noatime.../homeext4挂载参数:nobarrier这个主要是判断日志代码中是否使用写屏障(writebarrier),对日志提交进行正确的磁盘排序,让volatile磁盘写入缓存可以安全使用,但会带来一些性能损失。从功能的角度来看,它与writeback和orderedlog模式非常相似。这方面的源码我没研究过,可能是一样的吧。不管怎样,禁用写屏障无疑会提高写性能。#mount-o...nobarrier.../homeext4挂载参数:delallocdelalloc是delayedallocation的缩写,如果启用,ext4会延迟申请数据块,直到超时。为什么要延迟申请?在inode中,使用多级索引记录文件数据所在数据块的编号。如果出现大文件,会以extent段的形式进行分配,需要在inode中记录一个连续的block。起始块数和长度足够,不需要索引记录所有块。连续的block除了可以减轻inode的压力外,还可以将随机写入改为顺序写入,从而加快写入性能。连续块也符合局部性原则,可以增加预读时的命中概率,从而加快读取性能。#mount-o...delalloc.../homeext4挂载参数:inode_readahead_blksext4从inode表中预读的最大indoe块数。访问一个文件,必须通过inode获取文件信息和数据块地址。如果需要访问的inode都在内存中命中,就不需要从磁盘读取,无疑会提高读取性能。它的默认值为32,表示最大预读为32×block_size,即64K的inode数据。在内存充足的情况下,我们无疑可以进一步扩展,让更多的预读。#mount-o...inode_readahead_blks=4096.../homeext4挂载参数:journal_async_commitcommitblock可以直接写入磁盘,无需等待descriptorblock。这加快了日志记录。#mount-o...journal_async_commit.../homeext4挂载参数:commitext4一次缓存多少秒的数据。默认值为5,也就是说如果此时断电,你最多会丢失5s的数据。通过设置更大的数据,可以缓存更多的数据,相对断电时可能丢失更多的数据。这种情况下服务器不怕掉电,增加该值可以提高性能。#mount-o...commit=1000.../homeext4挂载参数汇总最后在无法umount的情况下,我执行的调整挂载参数的命令是:mount-oremount,rw,noatime,nobarrier,delalloc,inode_readahead_blks=4096,journal_async_commit,commit=1800/home另外在/etc/fstab中也做了修改,避免重启后优化丢失#cat/etc/fstabUUID=.../homeext4defaults,rw,noatime,nobarrier,delalloc,inode_readahead_blks=4096,journal_async_commit,commit=1800,data=writeback00...Pagecachepage缓存在FS和generalblock层之间,但也可以归入generalblock层。为了提高IO性能,减少实际从磁盘读写的次数,Linux内核设计了一层内存缓存,将磁盘数据缓存到内存中。由于内存以4K页为单位进行管理,而磁盘数据也以页为单位进行缓存,所以又称为页缓存。在每个缓存页面中,都包含了部分磁盘信息的副本。如果要读取的数据因为之前已经读写或预读加载而刚好命中缓存,则可以直接从缓存中读取,无需深入磁盘。不管是同步写还是异步写,数据都会被复制到缓存中。不同的是,异步写只是一个拷贝,页面标记脏后直接返回,而同步写也会调用类似fsync()的操作等待写回。详情参见内核函数generic_file_write_iter()。异步写入产生的脏数据会在“适当”的时候被内核工作队列回写进程刷回。那么,什么时候合适呢?最多可以缓存多少数据?对于本次优化的服务器来说,延迟刷新无疑可以减少频繁删除文件的磁盘写入次数,缓存更多的数据。更容易合并随机IO请求,这有助于提高性能。在/proc/sys/vm中,以下文件与flushdirtydata密切相关:配置文件函数默认值dirty_background_ratio触发闪回的脏数据占可用内存的百分比0dirty_background_bytes触发闪回的脏数据量10dirty_bytes触发同步写脏数据量0dirty_ratio脏数据触发同步写入可用内存的百分比20dirty_expire_centisecs脏数据超时刷新时间(单位:1/100s)3000dirty_writeback_centisecs刷新进程定时唤醒时间(单位:1/100s)500对于以上配置文件,有几点需要补充:XXX_ratio和XXX_bytes是同一个配置属性的不同计算方式,优先级XXX_bytes>XXX_ratio可用内存不是全部系统内存,而是freepages+reclaimablepages脏数据超时表示数据在内存脏了一段时间后,下一次刷新过程有效,必须刷新。刷新进程不仅会定时唤醒,还会在脏数据过多时被动唤醒。dirty_background_XXX和dirty_XXX的区别是前者只唤醒刷新进程。这个时候,应用程序仍然可以异步的向Cache中写入数据。当脏数据占比不断增加时,会触发dirty_XXX条件,不再支持应用异步写入。更完整的功能介绍可以看内核文档Documentation/sysctl/vm.txt,或者我写的一篇总结博客《Linux 脏数据回刷参数与调优》目前的情况,我的配置如下:dirty_background_ratio=60dirty_ratio=80dirty_writeback_centisecs=6000dirty_expire_centisecs配置of=12000有如下特点:当脏数据达到可用内存的60%时,唤醒刷新进程;当脏数据达到可用内存的80%时,应用程序必须每60s同步等待每条数据唤醒刷新如果进程内存中的脏数据存在时间超过120s,则在下次唤醒时刷新-向上。当然,为了避免重启后优化结果丢失,我们在/etc/sysctl.conf中这样写:#cat/etc/sysctl.conf...vm.dirty_background_ratio=60vm.dirty_ratio=80vm.dirty_expire_centisecs=12000vm.dirty_writeback_centisecs=6000请求层在异步写入的场景下,当脏页达到一定比例时,需要通过通用块层将pagecache中的数据刷回磁盘。bio层记录了磁盘块和内存页之间的关系。在请求层,将多个物理块连续的bios合并为一个请求,然后将系统中所有进程产生的IO请求按照特定的IO调度算法进行合并排序。.那么IO调度算法有哪些呢?网上搜了IO调度算法,很多资料都在描述Deadline、CFQ、NOOP这三种调度算法,但是没有说明这些只适用于单队列调度算法。在最新的代码上(我分析的代码版本是5.7.0),已经完全切换到新的多队列架构,支持的IO调度算法有mq-deadline、BFQ、Kyber、none。关于不同IO调度算法的优缺点网上有很多资料,本文不再赘述。《Linux-storage-stack-diagram_v4.10》中对BlockLayer的描述可以很形象地说明单队列和多队列的区别。单队列架构,一个块设备只有一个全局队列,所有的请求都要塞进这个队列。添加这个是为了在多核高并发的情况下保证互斥,尤其是32核服务器的情况下。锁导致非常大的开销。另外,如果磁盘支持多队列并行处理,单队列模型就无法发挥其优越的性能。在多队列架构下,创建了两个队列,Softwarequeues和Hardwaredispatchqueues。Softwarequeues是每个CPUcore一个队列,IO调度在里面实现。由于每个CPU都有一个独立的队列,所以不存在锁争用问题。HardwareDispatchQueues的数量与硬件情况有关。每个磁盘有一个队列。如果磁盘并行支持N个队列,那么也会创建N个队列。从Softwarequeues向HardwareDispatchQueues提交IO请求的过程中需要加锁。理论上,多队列架构最差的效率只与单队列架构相当。让我们回到当前要优化的服务器。当前正在使用什么IO调度程序?该服务器的#cat/sys/block/vda/queue/schedulernone#cat/sys/block/sda/queue/schedulernoop[deadline]cfq内核版本为#uname-r3.13.0-170-generic检查Linux内核git提交记录,发现适合多队列的IO调度算法在3.13.0内核版本上还没有实现,此时还没有完全切换到多队列。队列架构,所以使用单队列的sda设备仍然有传统的noop、deadline和cfq调度算法,而使用多队列的vda设备(virtio)的IO调度算法只有none。为了使用mq-deadline调度算法而升级内核的风险似乎很高。所以在IO调度算法上没有太多需要优化的地方。但是Request层的优化只能这样?既然IO调度算法无法优化,那么是否可以修改队列相关的参数呢?比如增加Request队列的长度,增加预读数据量。/sys/block/vda/queue中有两个可写文件nr_requests和read_ahead_kb。前者是配置块层可以申请的最大请求数,后者是最大预读数据量。默认情况下,nr_request=128read_ahead_kb=128我扩大为nr_request=1024read_ahead_kb=512优化效果优化后,在满负荷的情况下,查看内存使用情况:#cat/proc/meminfoMemTotal:49459060kBMemFree:1233512kBBuffers:12643752kBCached:21447280kBActive:19860928kBInactive:16930904kBActive(anon):2704008kBInactive(anon):19004kBActive(file):17156920kBInactive(file):16911900kB...Dirty:7437540kBWriteback:1456kB可以看到文件相关内存(Active(file)+Inactive(file))已经达到32.49GB,脏数据达到7.09GB。脏数据量低于预期,远低于dirty_background_ratio和dirty_ratio设置的阈值。因此,如果需要缓存更多的写入数据,只能延长定时唤醒刷新的时间dirty_writeback_centisecs。这个服务器主要是用来编译SDK的,读的需求比写的需求大很多,脏数据缓存多了意义不大。我还发现Buffers达到了12G,应该是ext4inode占用了很多cache。上面分析过,这个服务器的ext4有大量空闲的inode。在缓存的元数据中,无效inode的比例是未知的。减少inode的数量,提高inode的利用率,可以提高inode预读的命中率。优化后,让8个SDK同时并行编译,走完整个编译过程(包括更新代码、抓取提交、编译内核、编译SDK等),不进入界面耗时13分钟左右错误处理过程。这次优化到这里就结束了,如果后期使用过程中还有问题,我们可以进行调整。