贝壳Flutter瘦身实践

开源地址

背景

贝壳找房内部大部分的 App 都已经接入了 Flutter,而且公司在跨端方案的选择上在大力发展 Flutter 生态体系,越来越多的团队也在使用 Flutter,Flutter 虽然能带来高人效和高性能的体验,同时也导致包体积增加,包体积的增加会给我们的推广增大难度,所以我们迫切需要一套针对 Flutter 的通用瘦身方案。

现状:

以一个空工程为例,Flutter 产物主要包含两部分,App.framework 和 Flutter.framework 这两个库,这两个库达到了 16M,对我们的包体积优化会带来不小的压力,所以我们立项了 Thin-Flutter 项目,主要是为了所有 App 提供一套 Flutter 通用的瘦身方案。(由于安卓侧有比较多的手段来实现瘦身,所以本篇文章主要针对 iOS)

对于包大小问题,Flutter 官方也在持续跟进优化:

  • Flutter V1.2 开始支持Android App Bundles,支持 Dynamic Module 下发。

  • Flutter V1.12 优化了2.6% Android 平台 Hello World App 大小(3.8M -> 3.7M)。

  • Flutter V1.17 通过优化Dart PC Offset存储以减少 StackMap 大小等多个手段,再次优化了产物大小,实现18.5%的缩减

  • Flutter V1.20 通过Icon font tree shaking移除未用到的 icon fonts,进一步优化了应用大小。

  • Flutter V2.2.2 并没有明显的措施

我们以贝壳 flutter 产物为例(Flutter SDK : 1.22.4)

App.framework 总大小 20.8M

Flutter.framework 总大小 7.7M

我们先来分析 Flutter 的产物构成,通过对编译命令优化后,产物如下(Release 模式):

App.framework:其中两个文件占比较大,一个是 App 可执行文件,另一个是 flutter_assets. App 可执行文件是 Dart 侧业务代码 AOT 编译的产物,会随着业务量的增多而变大,flutter_assets 包含图片、字体等资源文件。

Flutter.framework:  引擎产物,大小是固定的,但是初始占比比较大。这部分能优化的空间很小,主要是通过裁剪引擎不需要的功能,减少体积。编译引擎时可以选择性编译 skia 和 boringssl,收益大概只有几百 K。

经过对比,iOS 和 Flutter 代码量增长对于包体积的影响是有很大区别的,由于 Flutter 的 Tree Shaking 机制,未被引用的代码都会被裁剪掉,这个机制 iOS 里是没有的,那么这个机制所造成的影响就是 Flutter 包体积在初期会极速增加,到一个临界点包体积的增加会趋于平缓。

贝壳瘦身方案

一、方案调研:

包体积瘦身方法论,大概就三种,要么删减,要么压缩,要么挪走,对于删减 Flutter 自带有 tree-sharking 机制,也就是没有用到的代码会自动裁剪,所以删减不会有太明显的效果,对于压缩,各个团队都会不定时压缩图片,所以不能作为主方案,那么想要有明显的瘦身效果,最好的方面很明显是挪了。下面是一些常用的瘦身方案:

  • 通过打包命令删减

—split-debug-info 可以分离出 debug info

—strip 去除无用符号

—dwarf_stack_trace 表示在生成的动态库文件中,不使用堆栈跟踪符号

—obfuscate 表示混淆,通过减少变量名/方法名的方式减小代码体积

  • 减少显示类型转换操作

as  String/Bool 等等,这类操作会导致 App.framework 体积显著增加,主要是他会增加类型检测及抛出异常的处理逻辑。

  • Flutter 引擎删减及符号化分离

Flutter 引擎中包括了 Dart、skia、boringssl、icu、libpng 等多个模块,其中 Dart 和 skia 是必须的,其他模块如果用不到倒是可以考虑裁掉,能够带来几百 k 的瘦身收益。业务方可以根据业务诉求自定义裁剪。

  • 无用代码及无用资源删除

这个不用过多解释,这种直接删代码删资源的方案是最常见的,但投入回报比并不高。

除了给各个业务线分配瘦身指标之外,通过其他方式达到了非常不错的瘦身效果,主要包括以下几方面:

  1. 去除符号化文件

  2. Flutter 产物数据段及资源文件动态下发

  3. 其他方式:包括去除无用文件、无用资源等

二、具体实现:

贝壳希望有一套长期有效的瘦身方案,以及监控体系,所以贝壳的瘦身方案包括两方面,一是包大小分析及监控,二是通用的,对业务同学无感知的瘦身方案。

1.监控

为了让 Flutter 包大小结构更加一目了然,我们将 Flutter 包大小进行了线上可视化。

首先,我们对 flutter_tools 了修改,在打包过程中我们会收集各个 Flutter 组件中二进制和资源的大小并写入文件(这里的实现我们参考了 flutter_tools 中 analyze_size.dart 的代码),打包完成后会将包大小分析文件上传至服务器;然后,我们在后端对上传的包大小文件进行分析,并将各个组件对应到相应的业务线;最终,我们将分析过后的包大小文件在前端进行展示。除了展示各组件和业务线的大小之外,我们还提供了 Flutter 包大小的对比功能,这样就可以更清晰的看到各个组件和业务线的前后大小变化。

有了包大小的分析,我们就可以根据各业务线和组件的不同情况制定不同的瘦身目标。

与此同时,我们可以对包大小的变化有一个长期的监控,可以及时发觉增量大的组件或者业务方,及时做出调整。

2.瘦身方案

2.1.去除符号化文件

贝壳通过 podspec 注入命令的方式,将 debug 符号信息剥离到指定目录,但这样会产生一个新的问题,Flutter 侧 error 无法解析,因此,我们在编译的同时将符号文件和 uuid 唯一标识绑定上传后端归档,在 App 的 Flutter 页面发生异常时,动态获取当前运行 app App.framework 组件的 uuid 标识,连同异常堆栈上报,后端根据 uuid 匹配符号文件,并解析异常堆栈,这部分可以瘦身 1.3M 左右。

以下是 Flutter error 的解析流程图:

由各个业务方梳理无用页面及无用资源文件、图片压缩等等,其中无用代码及无用资源删减有 2M 的收益,图片压缩有 800k 的收益。

我们完成剪裁以后,还需要进一步研究其他瘦身手段,比如动态下发。

2.2Flutter 产物数据段及资源的动态下发

是否可以将两个动态库 App.framework 和 Flutter.framework 全部动态下发?答案是不行的,原因是由于 iOS 系统的限制,可执行文件是不可以动态下发的。

那我们进一步分析,哪些东西是可以动态下发的,哪些东西是不可以动态下发的。参考图 1,首先 flutter_assets 和 icudtl.dat 是资源文件,不存在权限的问题,所以可以动态下发,那么除了这些资源文件,其他部分是否可以动态下发呢?我们接着看。

实现环境:Flutter SDK 2.2.2(目前 Flutter 官方已经发布了 Flutter 2 版本,贝壳也已经适配了 Flutter2.2.2 版本,因此后面基于 Flutter2.2.2 版本进行分析)首先我们看下 Flutter 是如何编译出产物的。

App.framework 的可执行文件经过编译命令优化后,主要由以下四部分构成:

kDartIsolateSnapshotData //代表 Dart 堆的初始状态,并包含 isolate 专属的信息。

kDartVmSnapshotData //代表 isolate 之间共享的 Dart 堆 (heap) 的初始状态。有助于更快地 启动 Dart isolate,但不包含任何 isolate 专属的信息。

kDartIsolateSnapshotInstructions //包含由 Dart isolate 执行的 AOT 代码。

kDartVmSnapshotInstructions //包含 VM 中所有 Dart isolate 之间共享的通用例程的 AOT 指令。这种快照的体积通常非常小,并且大多会包含程序桩 (stub)。

首先了解下什么是 isolate,DartVM 采用了所谓快照的方式,即 JIT 运行时编译后的基本结构与 AOT 编译的基本结构相同。将类信息、全局变量、函数指令直接以序列化的方式存在磁盘中,称为 Snapshot(快照)。同一个进程里可以有很多 isolate,但两个 isolate 的堆区是不能共享的,所以官方设计了 VM isolate,也就是 kDartVmSnapshot,用来多个 isolate 之间的交互。kDartVmSnapshot 分为指令段和数据段,对应上面的 kDartVmSnapshotData 和 kDartVmSnapshotInstructions,内置在 App.framework 里。具体关系如图:

而 isolate 对应的就是 kDartIsolateSnapshot,同样也分为指令段和数据段,对应上面的 kDartIsolateSnapshotData 和 kDartIsolateSnapshotInstructions。

官方文档解释 From the VM's perspective, this just needs to be loaded in memory with READ permissions and does not need WRITE or EXECUTE permissions. Practically this means it should end up in rodata when putting the snapshot in a shared library.

iOS 系统是不允许动态下发可执行二进制代码的,但 kDartIsolateSnapshotData 和 kDartVmSnapshotData 两个数据段的加载是不受系统限制的,所以我们要针对这两部分(上图黄色部分),制定具体的分离方案以及加载方案。

2.2.1、如何分离数据段并回写到磁盘

从上图可以看出 gen_snapshot 为 Dart 编译器,编译后的产物 snapshot_assembly.S 文件再根据不同的平台,编译出不通平台的产物。

分离数据段分为两部分:

  1. 将数据段回写到磁盘上,放入云端服务器动态下发。

  2. 将数据段从 App.framework 中剔除,达到瘦身效果

我们从 gen_snapshot 入手,将数据段产物剥离出来。具体实现如下:

经过验证,release 模式下走的是 CreateAndWritePrecompiledSnapshot 编译流程,因此我们将其改造,将数据段回写到磁盘,需要重写 CreateAppAOTSnapshotAsAssembly 方法。至于回写文件,我们发现 debug 模式下会使用 WriteFile 方法写入文件,这里仿照 debug 模式,将回传的数据段写入./ios/Flutter/Resource/路径下。

Dart_CreateAppAOTSnapshotAsAssembly 具体实现为在 dart_api_impl.cc 文件,gen_snapshot 编译器会将 dart 代码编译为 snapshot_assembly.S 文件,而 snapshot_assembly.S 文件实际上就包含了

kDartIsolateSnapshotData //数据段

kDartVmSnapshotData //数据段

kDartIsolateSnapshotInstructions //代码段

kDartVmSnapshotInstructions //代码段

这几部分。那么我们找到如何将数据段和代码段写入 snapshot_assembly.S 文件,把数据段分离出来不就可以了吗?我们在 FullSnapshotWriter 里发现了整个 snapshot_assembly.S 的写入过程。

到这一步我们已经将数据段回写到磁盘上了,Resource 下成功写入了两个数据段产物,如下图:

2.2.2、将数据段从可执行文件中剔除

同理,找到写入数据段的位置,将其剔除,我们本着改动量最小的原则,分析原有写入逻辑,发现源码里已经将不同符号类型的数据归类,那么顺着原有逻辑,在写入符号的时候,将数据段类型剔除即可。具体源码如下:

通过 nm 命令验证 App 文件中是否只剩下代码段:

对比剥离之前:

数据段剥离之后,也就完成了我们瘦身的目的,但是 App 运行时,没有数据段是不行的,会造成 App 崩溃,因此我们还需要一套完善的方案,来保证数据段从远端下发之后,安全的被加载。

2.2.3、如何加载分离后的数据段

我们来看下加载流程

Flutter 引擎启动的时候,会创建 DartVM,同时加载可执行文件中的代码段和数据段。具体方法可追溯到 ResolveVMData、ResolveVMInstructions、ResolveIsolateData、ResolveIsolateInstructions 等四个方法,分别加载了数据段与代码段,而这四个方法都指向了同一个方法,也就是 SearchMapping 方法,如下:

从 SearchMapping 方法中可以判断,加载顺序为,先从 settings.vm_snapshot_data 或 settings.isolate_snapshot_data 加载,若不存在则从 settings.vm_snapshot_data_path 或 settings.isolate_snapshot_data_path 读取,再然后从 settings.application_library_path 中加载。

那么如果我们篡改 settings.vm_snapshot_data_path 和 settings.isolate_snapshot_data_path 的指向,是否可以将我们本地的数据段正确加载呢?答案是肯定的。

从上面的加载流程图里可以看出来 Setting 类的初始化是在 FlutterDartProject 类里。我们在 FlutterDartProject 重设数据段路径、flutter_assets 路径和 icu_data 国际化文件路径。

首先我们在 App 启动时将分离产物下载到沙盒内的指定路径下:Document/flutter_resource/Resource/*

然后将 setting 类中 path 指定到此路径下。

设置完成以后,DartVM 启动所需要的各种资源与二进制就可以正常加载了。

工程化落地

有了初步的瘦身方案,具体落地还需要很多配套措施,比如持续集成,私有云及监控体系。

工程化落地主要包括三部分:

第一部分:定制 Flutter SDK

一、将 Flutter.framework 文件和 gen_snapshot 文件进行归档,同时需要制作 dSYM 符号表文件。

iOS 提供了两个工具,一个是用于 Flutter.framework 的规定及符号表导出,另一个是用于 gen_snapshot 文件的归档,他们位于 engine/src/flutter/sky/tools/create_ios_framework.py 和 engine/src/flutter/sky/tools/create_macos_gen_snapshots.py。

最终如图所示

ios-release 文件夹就是我们我最终改造完的产物,接下来就是定制 sdk 了。

实际上 Flutter sdk 里会根据不同的平台,不同的 build model 选择不同的编译器和 Flutter engine,如下图所示:

我们只需要把刚刚归档出来的 ios-release 替换 Flutter sdk 里的 ios-release 文件夹,之后 release 模式下打 iOS 产物,App.framework 就会是剥离出数据段的产物。

二、结合 flutterw 部署定制 sdk

由于目前公司 Flutter sdk 存在多个版本,比如 1.12.13、1.22.4 等,因此我们开发了 Flutter sdk 自动化管理工具 flutterw,可以根据项目的不同配置,切换不同的 Flutter sdk,包括官方 sdk,并且自动同步官方新版本。因此我们借助 flutterw 的能力,部署定制的 Flutter sdk,在有瘦身需求的项目里配置 sdk 版本即可。

首先发布定制 sdk:

结果如图:

接着在对应的项目中配置相应的 flutter sdk 版本,如下图:

三、改造 xcode_backend.sh 编译脚本,将数据段、资源包等压缩

编译之后产物被压缩成 flutter_resource.zip,同时为了标识产物的唯一性,将可执行文件的 uuid 作为唯一标识,每次下载完成之后需要先对比 uuid 是否一致。若不一致则更新产物。

第二部分:上传产物平台或内置压缩

到这一步我们准备了两种方案:

内置压缩方案:

也就是将数据段和资源包统一压缩内置在 App.framework 内,应用安装启动后自动解压放在指定位置。

动态下发方案:

对于其他小体量 App,可以采取远程下发的方案,也就是将 flutter_resource.zip 和 uuid_app.txt 上传到 s3 平台(资源服务器),同时在阿波罗平台(配置平台)增加新版本配置。应用启动后下载的方案。

方案对比:

两种方案对比之下,动态下发的瘦身效果最好,但成功率没有内置压缩高,内置压缩方案由于只增加了一个解压环节,因此成功率较高。不同的 APP 可以根据自己的需求采用不同的方案。

第三部分:产物管理

若使用远端下载方案,App 启动会首先拉取远端产物,并将版本信息生成缓存,校验 md5 通过后即可加载,当发现有新版本产物则拉取新版本产物并替换。至于内置压缩方案,则根据 App.framework 的 UUID 来判断是否是正确的产物。

内置压缩方案成功率达到了 99.99%,极小部分失败原因在于内存空间不足。

对于远程下发方案,App 启动后下载相关资源并解压,成功率会受到网络因素影响,增加了重试逻辑之后成功率如下:

尽管下载成功率达到了 99.4%但对于 C 端这种大体量的 app 来说,仍然会影响大量的用户,因此在 C 端使用的是成功率更高的内置压缩方案。

收益

通过动态下发这种方式,虽然可以显著的减小 Flutter 包体积,但是也会带来其他问题,比如由于网络原因导致产物下载失败。因此我们提供了更加安全可靠的方式,将这些文件压缩然后内置在 app 包内。

动态下发方案:

压缩内置方案:

优势:

1.通用的解决方案,任何接入 Flutter 的 APP 都可以用

2.只需集成一次,无需定时优化

3.随着 Flutter 业务的增多,瘦身效果也会更明显

劣势:

剥离出的产物需要通过网络下发,下载成功率取决于网络状况、内存空间等等因素制约。所以后续规划中,会结合 Flutter2web 来缓解由于下载失败,导致 Flutter 页面无法打开的情况。

符号化剥离及混淆:

注:Thin 模式就是数据段及资源动态下发或内置压缩的模式

总体瘦身:

经过上述方案的优化,Flutter 侧瘦身总大小达到了 7M 左右。

而经过各个业务方共同的努力,贝壳找房 app 包大小终于达标。以当时的 V2.47 版本为例:

“iPhone6-iPhoneX 系列”机型安装大小 149.2-149.8M,下载大小 110.5M

“iPhone11-12”机型安装大小 139.4M,下载大小仅 53M

Flutter engine 的改造源码目前已经开源,如果想尝试贝壳方案的同学可以按照开源文档接入。

开源地址:GitHub - LianjiaTech/flutter_beike_engine

后续规划

基于以上的优势劣势,贝壳致力于更加高标准的目标, 那么有没有其他办法在不影响成功率的情况下最大程度的增加瘦身比例呢?答案是有的,那就是结合 Flutter for web 来做兜底方案。具体方案如下:

我们知道,Flutter 在三端一体化做了大量的工作,Flutter 页面可以很好的被转换为 web 页面,我们可以借助这个特性,在编译发版包的时候,同时将 Flutter 工程编译为 Web 产物并部署在远端,当 App 启动后 Flutter 产物由于种种原因最终都无法下载成功的时候,自动打开对应的 web 页面。

关于 Fluttter for web 容灾降级更详细内容可参考:

https://mp.weixin.qq.com/s/zIeU0z-4P5Pd9THVybnDFQ

参考链接


mimalloc剖析

mimalloc是微软最近开源的一个malloc实现,其实验数据表明相比于jemalloc、tcmalloc等实现大约快了10%。其通过将空闲块列表(Free List)进行分片(Sharding)来保证分配的内存有更好的空间的局部性,从而提升性能。在mimalloc中一共进行了4次Free List的Sharding。接下来我们会分别介绍这4个Free List的Sharding的位置以及其为什么要进行Free List的Sharding。

在Mimalloc页中进行的Free List的Sharding

在其他的内存分配器的实现中,一般会为每一类大小的对象保留一个Free List,随着内存的不断分配与释放,这个列表中的对象可能散布在整个地址空间中,因此内存分配的局部性较差。而在mimalloc中,其通过将内存分页,每个页负责一个特定大小的内存的分配,每个页有一个Free List,因此内存分配的空间局部性较好。

其他内存分配器的Free List
其他内存分配器的Free List
mimalloc的Free List
mimalloc的Free List

Local Free List

mimalloc希望能够限制内存分配与内存释放在最差情况下的时间消耗,如果上层应用需要释放一个非常大的结构体,其可能需要递归的释放一些子结构体,因而可能带来非常大的时间消耗。因而在koka与lean语言中,其运行时系统使用引用计数来追踪各种数据,然后保留一个延迟释放的队列(deferred decrement list),当每次释放的内存块超过一定数量后就将剩余需要释放的内存块加入该队列,在之后需要释放的时候再进行释放。那么下一个需要确定的问题是什么时候再去从该队列中释放内存。从延迟释放队列中继续进行释放的时机最好应当是内存分配器需要更多空间的时候,因此这需要内存分配器与上层应用的协作。

在mimalloc中,其提供一个回调函数,当进行了一定次数内存的分配与释放后会主动调用该回调函数来通知上层应用。mimalloc在实现时检测当前进行内存分配的页的Free List是否为空,如果为空则调用该回调,但是为了避免用于一直不断的分配与释放内存,导致Free List一直不为空,而导致回调函数一直得不到回调。因此mimalloc将Free List第二次进行Sharding,将其分为Free List与Local Free List。

当内存在进行分配时会从对应页的Free List中取得内存块,而释放时会将内存块加入Local Free List中,因而在进行一定次数的内存分配后,Free List必定为空,此时可以进行deferred free的回调函数的调用。

Thread Free List

在mimalloc中每个堆都是一个Thread Local的变量,而每次进行内存分配时,其均会从这个Thread Local的堆中进行内存的分配,而释放时即可能从该线程中释放也可能从其他线程中进行释放。如果进行内存释放的线程是该堆的拥有者,则其释放的内存会加入到对应页的Local Free List中,而由于还可能有其他的线程来释放这些内存,因此mimalloc第三次进行Free List的Sharding,将Local Free List分为Local Free List与Thread Free List。在进行内存的释放时,如果释放的线程为内存块对应堆的拥有着则将其加入Local Free List,否则利用CAS操作将其加入Thread Free List中。mimalloc通过这次分割来保证堆的所有者线程在自己的堆上进行内存的释放是无锁的,从而提升一些性能上的表现。

Full List

第四次的Free List的Sharding其实来自于mimalloc自身的实现,其内存分配的伪代码如下。由于在mimalloc中每个堆中都有一个数组pages,该数组中每个元素都是一个由相应大小的页组成的队列;同时还有一个pages_direct的数组,该数组中每个元素对应一个内存块的大小类型,每个元素均为指向负责对应大小内存块分配的页的指针。因此mimalloc在进行内存分配时会首先从该数组指向的页中尝试进行分配,如果分配失败则调用malloc_generic,在该函数中会遍历pages数组中对应大小的队列,此时如果对应的队列中有很多页均是满的,且队列很长那么每次分配的时候都会进行队列的遍历,导致性能的损失。

因此mimalloc构建了一个Full List,将所有已经没有空闲空间的页放入该队列中,仅当该页中有一些空闲空间被释放后才会将其放回pages对应的队列中。而在由于内存的释放可能由对应堆的拥有者线程进行也可能由其他线程进行,因此需要一定的方式提醒对应的堆该页已经有空闲块了,同时为了避免使用锁导致的开销,mimalloc通过加入一个Thread Delayed Free List,如果一个页处于Full List中,那么在释放时会将内存块加入Thread Delayed Free List中,该队列会在调用malloc_generic时进行检测与清除(由于时Thread Local的堆,因此仅可能是拥有者来进行),因此此时仅需通过原子操作即可完成。那么还有一个问题是当释放内存的时候,其他线程如何知道是将内存块加入Thread Free List中还是Thread Delayed Free List中。mimalloc通过设置NORMAL、DELAYED、DELAYING三种状态来完成该操作。

总结

mimalloc通过将Free List进行分割,保证分配的内存具有较好的局部性并避免了锁的使用,从而获得了更好的性能。

参考链接


mimalloc剖析

高性能 PyTorch 训练:性能瓶颈的调查与分析

PyTorch

在 2014 年之前,神经网络与深度学习还没有大规模地应用于工业界。研究者们开发了一些基本而有效的工具包,来搭建神经网络。其中的代表就是 Caffe、Torch 和 Theano。由于当时的研究主流方向是卷积神经网络 (CNN) 在计算机视觉 (CV) 中的应用,所以这些框架主要关注的是 layers。这种设计完全可以满足研究者们拼接不同卷积层、了解不同神经网络结构效果的目的。

而在之后,随着循环神经网络 (RNN) 和自然语言处理 (NLP) 的兴起,以 layers 为“first class citizen”的工具包们就开始力不从心了。而工业界也开始对模型构建、训练以及部署的效率提出了新的要求。随着以 Google、Microsoft、Facebook、Amazon 等巨头的加入,以数据流 (Data Flow) 为中心的体系被提出,TensorFlow、CNTK、MXNet、PyTorch 等新一代的深度学习框架应运而生。

PyTorch 是一个基于 Torch 库的开源机器学习库,用于计算机视觉和自然语言处理等应用,主要由 Facebook 的人工智能研究实验室 (FAIR) 开发。它是在修改后的 BSD 2.0 许可协议下发布的免费开源软件。

正如同它名字的前缀一般,PyTorch 主要采用 Python 语言接口。与 TensorFlow 1.x 相比,PyTorch 的编写方式更简单自然,API 更 pythonic,对 debug 也更加友好。因此,PyTorch 在学术界赢得了更多的拥趸,近年来顶级会议中,PyTorch 的代码提交量遥遥领先于第二名的 TensorFlow (Keras)。而在工业界,PyTorch 后来居上,已经逐渐和 TensorFlow 分庭抗礼。

很多学术界最新的成果都是以 PyTorch 构建的,并被作者开源在了 GitHub。但也有很多声音表示 PyTorch 在训练中比 TensorFlow 更慢。

高性能 PyTorch 的训练流程是什么样的?是产生最高准确率的模型?是最快的运行速度?是易于理解和扩展?还是容易并行化?

答案是,上述所有。

结合我自己给 PyTorch 提速的经历,本文将给出一些提升 PyTorch 性能的方向。当然,作为本文的读者,您需要对 Linux 操作系统和 PyTorch 足够熟悉。

了解瓶颈所在

首先,当感受到训练缓慢时,我们应当检查系统的状态来得知代码的性能被哪些因素限制了。计算,是一个由应用程序(代码)、存储设备(硬盘、内存)、运算设备(CPU、GPU)共同参与的过程,有着非常明显的木桶效应,即其中任意一个环节都有可能成为性能瓶颈的来源。

工具

此时,熟悉一些运维工具可以有效地帮助你了解当前整个计算机以及各个硬件设备的工作状态。只有将性能瓶颈定位到 CPU、GPU、I/O 或是代码中,才能开始解决问题。

htop

htop 是一个跨平台的交互式流程查看器。

htop 允许垂直和水平滚动进程列表,以查看它们的完整命令行以及内存和CPU消耗等相关信息。显示的信息可以通过图形设置进行配置,并且可以交互地进行排序和过滤。与进程相关的任务(例如终止和更新)可以在不输入其 PID 的情况下完成。

从 htop 顶部的信息集合中,可以监视 CPU 和内存的使用情况。

一个好的高性能程序,应当尽可能多地进行异步运算,来充分发挥多核 CPU 的能力。同时,尽量多地使用内存,能够大大提高数据的交流效率。当然,这并不意味着你可以把所有的 CPU 和内存资源耗尽,这将使系统不能够正常调度资源,反而拖累计算。

在所有的 Linux 发行版中,你都可以直接从软件仓库中安装 htop,例如:

iotop 和 iostat

iotop 是用来监视每个命令所占用的 I/O 情况的命令行应用程序。

iostat 则是属于 sysstat 工具包中的一个组件,可以监视外部存储设备(硬盘)当前的 I/O 情况。

安装命令分别为:

需要注意的是,因为涉及到 I/O 情况的监视,所以以上两款程序均需要 root 权限才能正常运行。

nvidia-smi

NVIDIA System Management Interface 是基于 NVIDIA Management Library (NVML) 的命令行应用程序,旨在帮助管理和监视 NVIDIA GPU 设备。

一般来说,GPU 的流处理器使用率越高,就说明 GPU 是在以更高的效率运转的。设备的当前功率也能从侧面反映这个问题。换言之,如果你发现你的流处理器利用率低于 50%,则说明模型没能很好地利用 GPU 的并行能力。

在通过 sh 脚本安装 NVIDIA GPU 驱动后,nvidia-smi 会被自动安装。如果是从发行版的仓库中安装的驱动,可以尝试在软件源中搜索安装 nvidia-smi

nvtop

nvtop 代表 NVIDIA top,由开发者 Maxime Schmitt 发布于 GitHub,是一款用于观察和记录 NVIDIA GPU 使用情况的 (h)top 任务监视器。你会发现 nvtop 有着与 htop 非常相似的 UI。

它可以用于 GPU,并以曲线图的形式输出在一段时间内 GPU 流处理器和显存使用情况的变化。

相比于 nvidia-sminvtop 会关注到更关键的设备信息,并给出其在时间序列上的变化情况。

你可以参考 Syllo/nvtop 中的描述自行编译安装 nvtop

py-spy

py-spy 是一个针对 Python 程序的采样分析器。

它使您可以直观地看到 Python 程序正在花费时间,而无需重新启动程序或以任何方式修改代码。 py-spy 的开销非常低:为了提高速度,它是用 Rust 编写的,并且不会在所分析的 Python 程序相同的进程中运行。这意味着对生产 Python 代码使用 py-spy 是安全的。

py-spy 可以生成如下的 SVG 图像,来帮助你统计每一个 package、model 甚至每一个 function 在运行时所耗费的时间。

py-spy 同样能够以 top 的方式实时显示 Python 程序中哪些函数花费的时间最多。

只需要在 pypi 中安装即可:

问题分析与解决思路

有了以上工具所收集的信息,我们就可以开始分析限制程序性能发挥的原因了。

PyTorch Workflow

而首先,我们要了解 PyTorch 的工作流程。

因为 PyTorch 使用 Python 接口,同时在底层调用了相当多的 C 库,所以在使用 PyTorch 时,很多细节对用户是不暴露的。实际上,在常见的训练过程中,用户和 PyTorch 一起,大致完成了以下的步骤:

  1. 构建模型。将编写好的模型类实例化为 nn.Module 对象。
  2. 准备数据。将训练数据和测试数据进行预处理,然后组织为 Dataloader 的形式,并设置好数据增强方案。
  3. 定义 Loss Function 和 Optimizer。
  4. 主训练循环。

其中,主训练循环决定了网络经过多少次完整的数据集,即我们常说的 epoch:

  1. 从 Dataloader 中提取当前 batch 的数据。一般 Dataloader 中只记录了数据的 index 信息,所以每次训练循环时,对应的数据都会从硬盘被读取到内存,然后再从内存放入显存中,交由 GPU 进行后续步骤。
  2. 数据经过模型。
  3. 将得到的输出送到 Loss Function 中计算损失,随后进行 backward 求导。
  4. Optimizer 执行梯度下降,更新参数。

一般来说,PyTorch 训练的过程的快慢决定于主训练循环。主循环中的每一步都将被执行上万次乃至几十万次,任何的效率提升都能够带来极大的收益。

CPU 瓶颈

CPU 和 GPU 的计算特点,决定了它们不同的功用:CPU 具有更高的主频和精度,适合于进行串行任务;GPU 拥有几千到上万个 Stream 核心,可以进行大规模的并行任务。

所以,对于数据增强等没有相互依赖的任务交给 CPU 来进行,很大程度上会拖慢训练的进程。在每次的数据导入时,都会产生一定时间的等待。这是一种非常普遍的 CPU 瓶颈,即将不适合 CPU 的任务交给它来处理。

GPU 瓶颈

如果你熟悉梯度下降 (Gradient Descent) 的原理,那么你一定能够理解 batch size 对训练速度的影响。梯度下降将一个 batch 中的平均梯度作为总体梯度方向的近似,进行一次参数更新。Batch size 越大,那么 GPU 内同时并行计算的数据也就越多,相应的训练速度会有很大的提升。

Batch size 的设定对最终的训练结果有一定的影响,但是在一定范围内的调整并不会产生非常大的扰动。

主流观点中,在不过分影响最终的模型性能的前提下,batch size 的选取以最大化利用显存和流处理器为佳。

I/O 瓶颈

I/O 瓶颈是最常见、最普遍的训练效率影响因素。

正如上文中所描述的,数据将会在硬盘、内存和显存中不断地转移和复制。不同存储设备的读写速度,可能有几个数量级上的差异。

出现 I/O 瓶颈的标志主要有:

  1. 系统 I/O 读写(尤其是硬盘读写)占用过高;
  2. 内存占用和 CPU 占用都普遍偏低;
  3. 显存占用较高的情况下,流处理器的利用率过低。

将数据预读入内存中、异步进行数据加载都是有效的解决方案。一个简单稳定的方式是直接使用 DALI 库。

NVIDIA Data Loading Library (DALI) 是一个可移植的开源库,用于解码和增强图像、视频和语音,以加速深度学习应用程序。DALI 通过重叠训练和预处理减少了延迟和训练时间,缓解了瓶颈。它为流行的深度学习框架中内置的数据加载器和数据迭代器提供了一个插件,便于集成或重定向到不同的框架。

用图像训练神经网络需要开发人员首先对这些图像进行归一化处理。此外,图像通常会被压缩以节省存储空间。因此,开发人员构建了多阶段数据处理流程,包括加载、解码、裁剪、调整大小和许多其他增强算子。这些目前在 CPU 上执行的数据处理流水线已经成为瓶颈,限制了整体吞吐量。

DALI 是内置数据加载器和数据迭代器的高性能替代品。开发人员现在可以在 GPU 上运行他们的数据处理工作。

参考链接


高性能 PyTorch 训练 (1):性能瓶颈的调查与分析

nvidia-smi GPU性能状态(Performance State)含义

我正在使用Nvidia GTX Titan X进行深度学习实验。
我正在使用nvidia-smi来监视GPU的运行状态,但是提供的工具的性能(性能)状态没有意义。

我已经查看了nvidia-smi手册,它表示以下内容:

Performance State
The current performance state for the GPU. States range from P0 (maximum performance) to P12 (minimum performance).

如果不在GPU上运行任何进程(空闲状态),则GPU性能状态为p0。
但是,当运行一些计算繁重的过程时,状态变为p2。

我的问题是,为什么我的GPU闲置时处于P0状态,但是在执行繁重的计算任务时切换到P2? 不应该相反吗?

另外,有没有办法使我的GPU始终在P0状态下运行(最高性能)?


令人困惑。

但是,nvidia-smi手册是正确的。

当一个或一组GPU处于空闲状态时,在计算机上运行nvidia-smi的过程通常会使其中一个GPU退出空闲状态。这是由于该工具正在收集的信息-需要唤醒其中一个GPU。

此唤醒过程最初会将GPU置于P0状态(最高性能状态),但如果GPU空闲或不是特别忙碌,GPU驱动程序将监控该GPU,并最终开始降低性能状态以节省功耗。

另一方面,当GPU在工作负载下处于活动状态时,GPU驱动程序将根据其自身的启发式方法不断调整性能状态以提供最佳性能,同时使性能状态与实际工作负载相匹配。如果没有达到热或功率限制,则对于最活跃和最重的连续工作负载,性能状态应达到最高水平(P0)。

周期性很重但不连续的工作负载可能会导致GPU功耗状态在P0-P2级别附近波动。由于热(温度)或电源问题而"受限制"的GPU也可能会看到P状态降低。这种限制是显而易见的,并在nvidia-smi中单独报告,但是可能并非所有GPU类型都启用这种报告。

如果要在GPU上查看P0状态,我可以提供的最佳建议是运行短暂,繁重且连续的工作负载(例如,执行大型sgemm操作的工作),然后在该工作负载期间监视GPU。在这种情况下应该可以看到P0状态。

如果您使用的是正在使用cuDNN库的机器学习应用程序(例如Caffe),并且正在训练大型网络,则应该可以不时看到P0,因为cuDNN会执行类似于sgemm的操作通常情况下。

但是对于零星的工作负载,最常见的状态很有可能是P2。

要始终"强制" P0电源状态,可以尝试通过nvidia-smi工具尝试持久性模式和应用程序时钟。使用nvidia-smi --help或nvidia-smi的手册页了解选项。

尽管我认为这通常不适用于Tesla GPU,但除非特别设置更高的应用时钟,否则某些NVIDIA GPU可能会在计算负载下将自身限制为P2功耗状态。使用nvidia-smi -a命令查看可用于GPU的当前应用程序时钟,默认应用程序时钟和最大时钟。 (某些GPU(包括较旧的GPU)可能会在其中某些字段中显示N / A。这通常表明应用程序时钟无法通过nvidia-smi进行修改。)如果在计算负载期间卡似乎以P2状态运行,则可能通过将应用程序时钟增加到最大可用时钟(即最大时钟),可以将其增加到P0状态。使用nvidia-smi --help了解如何格式化命令以更改GPU上的应用程序时钟。修改应用程序时钟或启用可修改的应用程序时钟可能需要root / admin特权。设置GPU持久模式也可能是理想的或必要的。这将防止驱动程序在GPU活动期间"卸载",这可能导致驱动程序重新加载时重置应用程序时钟。

对于这种情况下受影响的卡,此默认行为是在计算负载下限制为P2,这是由GPU驱动程序设计的。

参考链接