再谈性能优化,一次项目优化经历分享

1. 前言

很高兴见到你。

关于性能优化,前面我已经写过一篇关于性能优化相关的文章,可以参考下,传送门:聊一聊性能优化,主要是分享一些简单的性能优化点,以及介绍什么是性能优化。

性能优化最本质的作用,无非就是两个方面:时间和空间。cpu、内存资源是有限的,如何在有限的资源,做更多的事情,就是性能优化的目标。性能差的程序带来的最大的问题,就是卡顿、闪退、发热、耗时等。但很多时候,在实际开发中,为了交付需求,往往会忽略了性能需求。例如在我们部分的外包同学,代码架构、性能往往都存在问题,导致后期需要专门腾出时间来做性能优化和重构。

这篇文章,主要基于我在项目做的一次比较完整的性能优化,分享一些关于性能优化的经验。文章会从我项目的问题出发,一步步来介绍我做性能优化的思路。希望对你有帮助。

那么,我们开始吧。

2. 项目介绍

我们项目的核心功能,是在端侧进行图像识别,并使用识别的结果完成后续的业务功能。在前台界面上会有一个进度条,等待进度条走完那么用户就可以看到结果。

在实际的体验中,我们发现一个问题:耗时实在是太久了,大部分用户等不及就离开了。从开始到结束看到结果,需要经历好几分钟的时间,迫切需要对耗时进行优化,产品预期希望一分钟内看到结果。

其次,我们也发现了很多的发热、卡顿现象。特别是在一些性能比较一般的机器中,尤为明显,整体的体验是比较差的。

3. 流程梳理

在进行性能优化之前,我们需要先理清,这个功能的整体流程是什么。这样才能知道哪些流程存在问题、需要优化。

核心流程可以总结为:读取图片到内存 -> 端侧算法内容识别 -> 业务逻辑。但完整的工程链路是比较长的,详细的流程参考下图:

流程很长哈,基本上分为三个阶段,简单介绍一下:

  • 在进行内容识别之前,有一个比较长的准备阶段,需要做一些预处理,如读取图片exif、下载算法模型等
  • 算法初始化结束后,循环进行内容识别,不断地对图片进行算法处理,并最后输出识别结果
  • 业务侧对输出的图像识别结果上传到服务端进行处理,最后回到本地进行展示

这是一个隐藏了业务细节和简化后的版本,但对于我们理解性能优化的问题也足够了。

在上述梳理流程的基础上,我们还需要对整体的耗时情况有一个概念,这样才能抓住重点。耗时情况如下:

到这里我们也就可以发现了,核心耗时主要有三个阶段,而且每个阶段的耗时都特别高:

  • 初始化阶段
  • 图像算法识别
  • 服务端数据处理

接下来我们就针对这些方面进行分析优化。

4. 耗时优化方案

多线程优化图像识别

首先针对算法分析阶段进行优化,他的流程如下:

  • 每一个媒体文件,都需要先经历读取素材bitmap,再进行算法识别的串行的流程
  • 读取素材bitmap等操作属于IO密集型,而算法图像识别属于CPU密集型,这两者并行化可以提高效率
  • 这种方式流程简单,但是整体的识别速度很慢,在高端机中cpu的利用程度低。单线程算法图片分析并没有利用好cpu资源,可以通过增加算法分析线程,来提高图像的识别速度。

因此我们需要做两件事:将读取图片操作和算法识别并行化;增加多线程来处理这两个任务。基于这个思路,我们使用生产-消费模型来设计整体的流程框架。

  • 整体模型参考 生产者-消费者 模型,将 读取图像 设置为生产者任务, 算法识别 设置为消费者任务
  • 通过增加线程,将生产任务和消费任务并行化,任务队列作为两端之间的缓冲。速度控制用于在消费者消费完一个任务后,中间的暂停间歇时间,可用于均衡cpu负载。
  • 模型的最大效率,取决于两端线程的上限,在充分利用cpu资源未耗尽的前提下,线程越多,效率越高;而反之,cpu资源耗尽,线程越多则效率越低。

基于这个模型,可以实现多线程任务处理、图像读取任务和算法图像识别并行化,达到我们的目的。但目前我们还有一些重要的问题需要解决:如何配置这个模型的参数,如队列长度、线程数量,来达到最大的效率?

算法识别对于手机的性能压力是比较大的,模型的瓶颈在于,如何尽可能多地跑算法分析。而图像读取是比较快的,且主要占用IO资源。因此,如何让消费端将性能打满是一个重要的目标,生产端的速度只要不拖后腿即可。

队列长度和生产者的参数如何配置? 这块的参数只需要满足消费者的使用即可,尽量不要让消费者暂停下来。经过实际运行,生产者的平均速度大约为消费者的一半,因此生产线程的数量在消费线程的2倍即可。 队列长度的设置目的是缓冲,同时为了避免生产者频繁暂停唤醒,长度设置为 生产者数量x2 + 2即可。生产者的数量也不宜过大,过大会导致生产线程频繁挂起和唤醒,也会影响性能。

在我们的项目中,经过这个优化,在高端机中,整体耗时可以降低50%以上,妥妥的优化利器。

初始化阶段优化

优化完算法识别,我们再看回来初始化阶段的流程:

初始化阶段做的事情很多,且都是耗时操作。但是仔细一看可以发现,有一些任务之间是没有依赖关系的,处理为并行操作可以减少很多的耗时。如下:

并行化后,由于读取素材的exif、请求POI等操作耗时明显较长,约等于节省掉下载算法模型和RPC请求的耗时。

那这就是初始化阶段优化的极限了吗?我们可以看到读取exif和POI请求耗时还是非常久的,特别是在于用户的素材特别多的情况下。我们真的需要在初始化阶段去将所有素材的exif和POI完全获取吗?答案是否定的,因为素材的这些信息只在算法识别的时候做为辅助信息进行处理。那么优化方案就浮出水面了:定义一个独立模块异步处理这两块任务,外部需要的时候读取即可。优化后的流程如下:

经过这个优化后,初始化阶段的耗时基本可以控制在 下载算法模型 + 算法初始化 这两块了,从103s 优化到 33s。

再一次提问,这就是极限了吗?对于这些任务,本身并不是高负载任务,可以将这些任务进行预处理,在进入该功能之前处理完成,那么可以进一步压缩耗时时间。但需要注意的是,下载算法模型需要占用带宽,需要和其他业务明确好,不能影响了其他的业务;又例如读取素材exif,这需要相册权限,也需要在合适的时机去完成。

这里还有一个重点需要提一下。我们可以看到这里面用了非常多的线程,初始化阶段、exif读取、算法识别、图像读取等等,如果真的都创建了线程会导致app的线程数量突增,可能并达不到理想的优化效果。

所以这里需要使用线程池来进行统一管理和优化,将每个地方的线程数量 修改为 最大并发量。例如原本我们可能需要增加3个线程来负责图像读取,那么这时就需要改为使用线程池并限制该任务的最大并发量为3。可能没办法达到最理想的优化效果,但是能避免线程数量爆炸带来的劣化。

其他方面优化

我们上述的优化,都是针对在客户端,也就是我们自己负责的模块。这轮优化下来之后可以看到耗时降低了不少,但是整体的耗时还是比较高的。如果需要再进一步优化,那么我们需要考虑从其他方向入手,例如服务端等。我们一个个来。

服务端的整体时间在64s,肯定是还存在优化空间的。在实际的项目中也是,经过和服务单的协调优化后,最终他们的耗时可以降低一半,在30s左右。

第二个地方是算法。算法有两个地方可以优化:算法模型大小、算法图像识别速度。这块在项目中经过优化后,模型大小减少70%,识别耗时降低50%。

到这里我们的总耗时已经优化到了:90s左右。距离产品的1min还是差了比较多。但基本上我们能做的都做了,那该怎么办呢?调整产品策略

我们可以看到,我们是将所有的图片分析结束后,再产出结果。但可以有一种方法是:只需要分析一部分,就先产出一个结果,给到用户反馈。再在后台不断分析剩下的图片,结束后再给予用户完整的结果。这就有点类似我们在去餐厅点餐,是一次性将菜品上齐,还是好了一个菜品就上一个,显然后者会有更好的用户体验。

经过产品策略调整后,整体的耗时下降到50s以内,满足产品预期需求。

这里的话就有一个很重要的思路:以产品需求为目标,视角不局限于自身模块。假如只针对客户端进行优化,那么怎么做都很难达到60s的耗时预期。当推动了算法、服务单、产品调整后,这个结果就能够很快达成,也更加合理。

代码细节优化

上面我们所讲的优化,都是基于业务流程来进行的优化。但其实也有相当一部分优化,在于代码的质量。例如重复加载bitmap、在主线程做耗时操作等等,需要我们去根据具体的代码细节进行优化。那这块怎么做呢?

第一是在写代码的时候,注重代码的性能表现。我们常说的算法题是一个很好的思路,通过思考代码的时间和空间属性,来提高代码的性能。也有一些书籍可以参考,例如 《Effective Java》等。

第二是通过一些工具来做性能检测。常见的问题就是内存泄露和UI卡顿。Android studio中有一个很好用的工具 Profiler,他可以检测内存分配的函数、在某个卡顿的帧做了什么事情。也可以打开strictMode,通过日志来看哪些耗时的操作在主线程运行。

业务流程的优化是很好通过后期的分析来提升性能,但是代码的质量往往很难,需要仔细阅读代码细节才能发现其中的问题。所以不断提升对性能的敏感度,并积累一些高性能代码的知识,才是重中之重。

5. 负载控制

那现在耗时目标已经达到,这优化就结束了吗?

当然不是。现在还有一个更重要的事情:机器负载。我们上述的优化,新建了很多的线程,整机的cpu、内存负载都上升不少。在机器性能比较差的机型上,就会导致比较严重的问题:卡顿、发热、闪退。其次,即使在性能比较好的机器,他也可能当前机器的负载本身就比较高,例如后台挂着个游戏等。因此我们需要根据机器的具体情况,控制好负载程度,避免这些问题的出现,提升功能的稳定性。

首先我们需要明确一个点:如何控制负载程度。

回顾我们前面的整体流程,可以发现最核心的负载因素就是图像算法识别这个过程。前面的读取exif、RPC请求等都是以IO为主,影响性能表现比较小。而决定图像算法识别负载的因素是:最大算法识别并发数、每次识别结束后的暂停时间

最大算法识别并发数 决定了同时最多有多少个算法实例在运行,也决定了队列缓存的数量,直接影响了CPU负载和内存负载。

每次识别结束后的暂停时间主要是用于均衡cpu负载,在一些性能比较差的机器中,开一个线程对于cpu负载可能就已经比较高了,那么为了可以运行此功能,可以每识别结束一个图片,休息500ms。这样一段时间内整体上cpu负载就可以得到降低了。

现在我们已经明确了如何控制机器的负载,那么该怎么去设置负载参数呢?为了解决这个问题,我们引入了两个方案:静态分级和动态降级

静态分级,指的是通过评价机器本身的硬件条件,来决策他们运行的最大负载。硬件条件包括 SOC、内存等。在我们的项目中,主要的评价对象就是机器的SOC。我们根据机器的SOC条件,划分出了4个档位:

  • 低端机:无法运行功能
  • 中端机:可以单线程运行,但需要增加休息时间
  • 高端机:可以单线程运行
  • 顶端机:可以多线程运行

具体划分的方式,是通过寻找对应soc的机器进行运行,测量他的负载表现来决策。

静态分级是比较粗粒度的,而且无法代表当前运行机器的实际情况。例如可能当前的手机正打着游戏、或者处在高温环境等。在发热之后,实际的运行效率会降低很多,手机的soc也会进行降频,此时如果强行开启多线程优化可能会适得其反,而且会导致手机发热严重。那么我们还需要根据用户机器的实际情况,进行动态降级。

动态降级的因素可以依据:电池温度、内存占用、CPU占用。电池温度是最能表现目前手机的状态的,温度过高的时候代表当前机器情况较差。内存占用过高也会导致运行效率下降,或者OOM,需要降低并发数来降低内促压力。CPU占用一般来说波动比较大,如果连续几次检测到都比较高,那很可能目前机器的负载就是很高。

下面举一个应用例子来说明:

  1. 启动时,根据用户剩余的内存和电池温度来决定是否开启多算法实例 a. 一个算法实例大概200mb,至少需要1GB剩余内存才能够开启多算法实例 b. 温度需要低于38度
  2. 运行时,每间隔10次图像运行,监控一次CPU状态和电池温度,来调整分析间隔 a. 每次调整100ms,最高1000ms,最低按照机型静态分级来配置 b. 按照38-42度,cpu负载50-100平均划分。例如起始间隔是0,温度40度,但是cpu负载只有60%,那么最高就只能配置500ms。
  3. 根据场景来配置:前台全量运行,离开前台后需要增加休息间隔来减少压力。

当然这些数据都是不严谨的,实际操作的时候,需要实际真机去运行、以及产品设计等来共同决策。

这个思路可以用到很多的高负载功能下,例如直播、视频编辑等等,核心思路就是 负载调整+静态分级+动态降级

7. 最后

好了,到这里关于这个项目的性能优化就讲完了,我们再来回顾一下整体的性能优化流程:

  1. 明确性能优化的目标,例如是降低耗时、降低内存占用,目标的水位是多少。
  2. 梳理整体流程和每个细节流程的耗时,确定需要优化的环节。
  3. 从全链路,多个角度来进行优化:自己负责的模块、其他模块链路、产品设计。
  4. 优化的方式可以有:多线程并行、预处理、流程简化、代码细节优化等。
  5. 需要注意整体功能的负载,通过负载调节 + 静态分级 + 动态降级来进行调整。

我还是之前的观点,性能优化不是高深莫测的东西,而是在我们开发的方方面面。这篇文章并没有深入到代码细节,而是主要着重于业务流程、机器负载等方面来聊,具体到执行还有很多的事要做。

希望文章对你有帮助。

相关推荐
阿巴斯甜4 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker4 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq95275 小时前
Andorid Google 登录接入文档
android
黄林晴7 小时前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab19 小时前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿1 天前
Android MediaPlayer 笔记
android
Jony_1 天前
Android 启动优化方案
android
阿巴斯甜1 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇1 天前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_1 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android