Baseline Profile 安装时优化在西瓜视频的实践

在Android上,Java/Kotlin代码会编译为DEX字节码,在运行期由虚拟机解释执行。但是,
首页 新闻资讯 行业资讯 Baseline Profile 安装时优化在西瓜视频的实践

665db2c35433587039b598072368788a34f4c2.jpg

背景

在Android 5,Google采用的策略是在应用安装期间对APP的全量DEX进行AOT优化。AOT优化(Ahead of time),就是在APP运行前就把DEX字节码编译成本地机器码。虽然运行效率相比DEX解释执行有了大幅提高,但由于是全量AOT,就会导致用户需要等待较长的时间才能打开应用,对于磁盘空间的占用也急剧增大。

于是,为了避免过早的资源占用,从Android 7开始便不再进行全量AOT,而是JIT+AOT的混合编译模式。JIT(Just in time),就是即时优化,也就是在APP运行过程中,实时地把DEX字节码编译成本地机器码。具体方式是,在APP运行时分析运行过的热代码,然后在设备空闲时触发AOT,在下次运行前预编译热代码,提升后续APP运行效率。

但是热代码代码收集需要比较长周期,在APP升级覆盖安装之后,原有的预编译的热代码失效,需要再走一遍运行时分析、空闲时AOT的流程。在单周迭代的研发模式下问题尤为明显。

因此,从Android 9 开始,Google推出了Cloud Profiles技术。它的原理是,在部分先安装APK的用户手机上,Google Play Store收集到热点代码,然后上传到云端并聚合。这样,对于后面安装的用户,Play Store会下发热点代码配置进行预编译,这些用户就不需要进行运行时分析,大大提前了优化时机。不过,这个收集聚合下发过程需要几天时间,大部分用户还是没法享受到这个优化。

最终,在2022年Google推出了 Baseline Profiles (https://developer.android.com/topic/performance/baselineprofiles/overview?hl=zh-cn)技术。它允许开发者内置自己定义的热点代码配置文件。在APP安装期间,系统提前预编译热点代码,大幅提升APP运行效率。


图片


不过,Google官方的Baseline Profiles存在以下局限性:

  • Baseline Profile 需要使用 AGP 7 及以上的版本,公司内各大APP的版本都还比较低,短期内并不可用

  • 安装时优化依赖Google Play,国内无法使用

为此,我们开发了一套定制化的Baseline Profiles优化方案,可以适用于全版本AGP。同时通过与国内主流厂商合作,推进支持了安装时优化生效。

方案探索与实现

我们先来看一下官方Baseline Profile安装时优化的流程:


图片


这里面主要包含3个步骤:

  1. 热点方法收集,通过本地运行设备或者人工配置,得到可读格式的基准配置文本文件(baseline-prof.txt)

  2. 编译期处理,将基准配置文本文件转换成二进制文件,打包至apk内(baseline.prof和baseline.profm),另外Google Play服务端还会将云端profile与baseline.prof聚合处理。

  3. 安装时,系统会解析apk内的baseline.prof二进制文件,根据版本号,做一些转换后,提前预编译指定的热点代码为机器码。

热点方法收集

官方文档(https://developer.android.com/topic/performance/baselineprofiles/create-baselineprofile)提到使用Jetpack Macrobenchmark库(https://developer.android.com/macrobenchmark)BaselineProfileRule自动收集热点方法。通过在Android Studio中引入Benchmark module,需要编写相应的Rule触发打包、测试等流程。

从下面源码可以看到,最终是通过profman命令可以收集到app运行过程中的热点方法。

privatefunprofmanGetProfileRules(apkPath:String,pathOptions:List<String>):String{// When compiling with CompilationMode.SpeedProfile, ART stores the profile in one of// 2 locations. The `ref` profile path, or the `current` path.// The `current` path is eventually merged  into the `ref` path after background dexopt.val profiles=pathOptions.mapNotNull{currentPath->Log.d(TAG,"Using profile location: $currentPath")val profile=Shell.executeScriptCaptureStdout("profman --dump-classes-and-methods --profile-file=$currentPath --apk=$apkPath")profile.ifBlank{null}}...returnbuilder.toString()}

所以,我们可以绕过Macrobenchmark库,直接使用profman命令,减少自动化接入成本。具体命令如下:

adb shell profman--dump-classes-and-methods \--profile-file=/data/misc/profiles/cur/0/com.ss.android.article.video/primary.prof \--apk=/data/app/com.ss.android.article.video-Ctzj32dufeuXB8KOhAqdGg==/base.apk \>baseline-prof.txt

生成的baseline-prof.txt文件内容如下:

PLcom/bytedance/apm/perf/b/f;->a(Lcom/bytedance/apm/perf/b/f;)Ljava/lang/String;PLcom/bytedance/bdp/bdpbase/ipc/n$a;->a()Lcom/bytedance/bdp/bdpbase/ipc/n;HSPLorg/android/spdy/SoInstallMgrSdk;->initSo(Ljava/lang/String;I)ZHSPLorg/android/spdy/SpdyAgent;->InvlidCharJudge([B[B)VLanet/channel/e/a$b;Lcom/bytedance/alliance/services/impl/c;...

这些规则采用两种形式,分别指明方法和类。方法的规则如下所示:

[FLAGS][CLASS_DESCRIPTOR]->[METHOD_SIGNATURE]

FLAGS表示 HSP 中的一个或多个字符,用于指示相应方法在启动类型方面应标记为 HotStartup 还是 Post Startup

  • 带有 H 标记表示相应方法是一种“热”方法,这意味着相应方法在应用的整个生命周期内会被调用多次。

  • 带有 S 标记表示相应方法在启动时被调用。

  • 带有 P 标记表示相应方法是与启动无关的热方法。

类的规则,则是直接指明类签名即可:

[CLASS_DESCRIPTOR]

不过这里是可读的文本格式,后续还需要进一步转为二进制才可以被系统识别。

另外,release包导出的是混淆后的符号,需要根据mapping文件再做一次反混淆才能使用。

编译期处理

在得到base.apk的基准配置文本文件(baseline-prof.txt)之后还不够,一些库里面

(比如androidx的库里https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:recyclerview/recyclerview/src/main/baseline-prof.txt

也会自带baseline-prof.txt文件。所以,我们还需要把这些子library内附带的baseline-prof.txt取出来,与base.apk的配置一起合并成完整的基准配置文本文件。

接下来,我们需要把完整的配置文件转换成baseline.prof二进制文件。具体是由AGP 7.x内的 CompileArtProfileTask.kt 实现的 :

/**
 * Task that transforms a human readable art profile into a binary form version that can be shipped
 * inside an APK or a Bundle.
 */abstractclassCompileArtProfileTask:NonIncrementalTask(){...abstractclassCompileArtProfileWorkAction:ProfileAwareWorkAction<CompileArtProfileWorkAction.Parameters>(){override funrun(){val diagnostics=Diagnostics{error->throwRuntimeException("Error parsing baseline-prof.txt : $error")}val humanReadableProfile=HumanReadableProfile(parameters.mergedArtProfile.get().asFile,diagnostics)?:throwRuntimeException("Merged ${SdkConstants.FN_ART_PROFILE} cannot be parsed successfully.")val supplier=DexFileNameSupplier()val artProfile=ArtProfile(humanReadableProfile,if(parameters.obfuscationMappingFile.isPresent){ObfuscationMap(parameters.obfuscationMappingFile.get().asFile)}else{ObfuscationMap.Empty},//need to rename dex files with sequential numbers the same way [DexIncrementalRenameManager] doesparameters.dexFolders.asFileTree.files.sortedWith(DexFileComparator()).map{DexFile(it.inputStream(),supplier.get())})// the P compiler is always used, the server side will transcode if necessary.parameters.binaryArtProfileOutputFile.get().asFile.outputStream().use{artProfile.save(it,ArtProfileSerializer.V0_1_0_P)}// create the metadata.parameters.binaryArtProfileMetadataOutputFile.get().asFile.outputStream().use{artProfile.save(it,ArtProfileSerializer.METADATA_0_0_2)}}}

这里的核心逻辑就是做了以下3件事:

  1. 读取baseline-prof.txt基准配置文本文件,下文用HumanReadableProfile表示

  2. 将HumanReadableProfile、proguard mapping文件、dex文件作为输入传给ArtProfile

  3. 由ArtProfile生成特定版本格式的baseline.prof二进制文件

ArtProfile类是在profgen子工程内实现的,其中有两个关键的方法:

  • 构造方法:读取HumanReadableProfile、proguard mapping文件、dex文件作为参数,构造ArtProfile实例

  • save()方法:输出指定版本格式的baseline.prof二进制文件

参考链接:

https://cs.android.com/android-studio/platform/tools/base/+/mirror-goog-studio-main:profgen/profgen/src/main/kotlin/com/android/tools/profgen/

至此,我们可以基于profgen开发一个gradle plugin,在编译构建流程中插入一个自定义task,将baseline-prof.txt转换成baseline.prof,并内置到apk的asset目录。


图片


核心代码如下:

val packageAndroidTask=variant.variantScope.taskContainer.packageAndroidTask?.get()packageAndroidTask?.doFirst{vardexFiles=collectDexFiles(variant.packageApplication.dexFolders)dexFiles=dexFiles.sortedWith(DexFileComparator())//基准配置文件的内存表示varhrp=HumanReadableProfile("baseline-prof.txt")varobfFile:File?=getObfFile(variant,proguardTask)val apk=Apk(dexFiles,"")val obf=if(obfFile!=null)ObfuscationMap(obfFile)elseObfuscationMap.Emptyval profile=ArtProfile(hrp!!,obf,apk)val dexoptDir=File(variant.mergedAssets.first(),profDir)if(!dexoptDir.exists()){dexoptDir.mkdirs()}val outFile=File(dexoptDir,"baseline.prof")val metaFile=File(dexoptDir,"baseline.profm")profile.save(outFile.outputStream(),ArtProfileSerializer.V0_1_0_P)profile.save(metaFile.outputStream(),ArtProfileSerializer.METADATA_0_0_2)}

自定义task主要包含以下几个步骤:

  1. 解压apk获取dex列表,按照一定规则排序(跟Android的打包规则有关,dex文件名和crc等信息需要和prof二进制文件内的对应上)

  2. 通过ObfuscationMap将baseline-prof.txt文件中的符号转换成混淆后的符号

  3. 通过ArtProfile按照一定格式转换成baseline.prof与baseline.profm二进制文件

其中有两个文件:

  • baseline.prof:包含热点方法id、类id信息的二进制编码文件

  • baseline.profm:用于高版本转码的二进制扩展文件

关于baseline.prof的格式,我们从ArtProfileSerializer.kt的注释可以看到不同Android版本有不同的格式。Android 12 开始需要另外转码才能兼容,详见可以看这个issue:

参考链接:https://issuetracker.google.com/issues/234353689

安装期处理

在生成带有baseline.prof二进制文件的APK之后,再来看一下系统在安装apk时如何处理这个baseline.prof文件(基于Android 13源码分析)。本地测试通过adb install-multiple release.apk release.dm命令执行安装,然后通过Android系统包管理子系统进行安装时优化。

Android系统包管理框架分为3层:

  1. 应用层:应用通过getPackageManager获取PMS的实例,用于应用的安装,卸载,更新等操作

  2. PMS服务层:拥有系统权限,解析并记录应用的基本信息(应用名称,数据存放路径、关系管理等),最终通过binder系统层的installd系统服务进行通讯

  3. Installd系统服务层:拥有root权限,完成最终的apk安装、dex优化


图片


其中处理baseline.prof二进制文件并最终指导编译生成odex的主要路径如下:

InstallPackageHelper.java#installPackagesLIInstallPackageHelper.java#executePostCommitStepsArtManagerService.java#prepareAppProfilesInstaller.java#prepareAppProfileInstalldNativeService.cpp#prepareAppProfile
                    dexopt.cpp#prepare_app_profileProfileAssistant.cpp#ProcessProfilesInternalPackageDexOptimizer.java#performDexOptPackageDexOptimizer.java#performDexOptLIPackageDexOptimizer.java#dexOptPathInstalldNativeService.cpp#dexopt
                       dexopt.cpp#dexopt
                           dex2oat.cc

在入口installPackagesLI函数中,经过prepare、scan、Reconcile、Commit 四个阶段后最终调用executePostCommitSteps完成apk安装、prof文件写入、dexopt优化:

privatevoidinstallPackagesLI(List<InstallRequest>requests){//阶段1:prepareprepareResult=preparePackageLI(request.mArgs,request.mInstallResult);//阶段2:scanfinalScanResultresult=scanPackageTracedLI(prepareResult.mPackageToScan,prepareResult.mParseFlags,prepareResult.mScanFlags,System.currentTimeMillis(),request.mArgs.mUser,request.mArgs.mAbiOverride);//阶段3:ReconcilereconciledPackages=ReconcilePackageUtils.reconcilePackages(reconcileRequest,mSharedLibraries,mPm.mSettings.getKeySetManagerService(),mPm.mSettings);//阶段4:Commit并安装commitRequest=newCommitRequest(reconciledPackages,mPm.mUserManager.getUserIds());executePostCommitSteps(commitRequest);}

executePostCommitSteps中,主要完成prof文件写入与dex优化:

privatevoidexecutePostCommitSteps(CommitRequestcommitRequest){for(ReconciledPackagereconciledPkg:commitRequest.mReconciledPackages.values()){finalAndroidPackagepkg=reconciledPkg.mPkgSetting.getPkg();finalStringpackageName=pkg.getPackageName();finalStringcodePath=pkg.getPath();//步骤1:prof文件写入// Prepare the application profiles for the new code paths.// This needs to be done before invoking dexopt so that any install-time profile// can be used for optimizations.mArtManagerService.prepareAppProfiles(pkg,mPm.resolveUserIds(reconciledPkg.mInstallArgs.mUser.getIdentifier()),/* updateReferenceProfileCnotallow= */true);//步骤2:dex优化,在开启baseline profile优化之后compilation-reasnotallow=install-dmfinalintcompilationReason=mDexManager.getCompilationReasonForInstallScenario(reconciledPkg.mInstallArgs.mInstallScenario);DexoptOptionsdexoptOptions=newDexoptOptions(packageName,compilationReason,dexoptFlags);if(performDexopt){// Compile the layout resources.if(SystemProperties.getBoolean(PRECOMPILE_LAYOUTS,false)){mViewCompiler.compileLayouts(pkg);}ScanResultresult=reconciledPkg.mScanResult;mPackageDexOptimizer.performDexOpt(pkg,realPkgSetting,null/* instructionSets */,mPm.getOrCreateCompilerPackageStats(pkg),mDexManager.getPackageUseInfoOrDefault(packageName),dexoptOptions);}// Notify BackgroundDexOptService that the package has been changed.// If this is an update of a package which used to fail to compile,// BackgroundDexOptService will remove it from its denylist.BackgroundDexOptService.getService().notifyPackageChanged(packageName);notifyPackageChangeObserversOnUpdate(reconciledPkg);}PackageManagerServiceUtils.waitForNativeBinariesExtractionForIncremental(incrementalStorages);}

prof文件写入

先来看下prof文件写入流程,主要流程如下图所示:


图片


其入口在ArtManagerService.java``#``prepareAppProfiles

/**
     * Prepare the application profiles.
     *   - create the current primary profile to save time at app startup time.
     *   - copy the profiles from the associated dex metadata file to the reference profile.
     */publicvoidprepareAppProfiles(AndroidPackagepkg,@UserIdIntintuser,booleanupdateReferenceProfileContent){try{ArrayMap<String,String>codePathsProfileNames=getPackageProfileNames(pkg);for(inti=codePathsProfileNames.size()-1;i>=0;i--){StringcodePath=codePathsProfileNames.keyAt(i);StringprofileName=codePathsProfileNames.valueAt(i);StringdexMetadataPath=null;// Passing the dex metadata file to the prepare method will update the reference// profile content. As such, we look for the dex metadata file only if we need to// perform an update.if(updateReferenceProfileContent){FiledexMetadata=DexMetadataHelper.findDexMetadataForFile(newFile(codePath));dexMetadataPath=dexMetadata==null?null:dexMetadata.getAbsolutePath();}synchronized(mInstaller){booleanresult=mInstaller.prepareAppProfile(pkg.getPackageName(),user,appId,profileName,codePath,dexMetadataPath);}}}catch(InstallerExceptione){}}

其中dexMetadata是后缀为.dm的压缩文件,内部包含primary.prof、primary.profm文件,apk的baseline.prof、baseline.profm会在安装阶段转为成dm文件。

mInstaller.prepareAppProfile最终会调用到dexopt.cpp#prepare_app_profile中,通过fork一个子进程执行profman二进制程序,将dm文件、reference_profile文件(位于设备上固定路径,存储汇总的热点方法)、apk文件作为参数输入:

//frameworks/native/cmds/installd/dexopt.cppboolprepare_app_profile(conststd::string&package_name,userid_t user_id,appid_t app_id,conststd::string&profile_name,conststd::string&code_path,conststd::optional<std::string>&dex_metadata){// We have a dex metdata. Merge the profile into the reference profile.unique_fd ref_profile_fd=open_reference_profile(multiuser_get_uid(user_id,app_id),package_name,profile_name,/*read_write*/true,/*is_secondary_dex*/false);unique_fddex_metadata_fd(TEMP_FAILURE_RETRY(open(dex_metadata->c_str(),O_RDONLY|O_NOFOLLOW)));unique_fdapk_fd(TEMP_FAILURE_RETRY(open(code_path.c_str(),O_RDONLY|O_NOFOLLOW)));RunProfmanargs;args.SetupCopyAndUpdate(dex_metadata_fd,ref_profile_fd,apk_fd,code_path);pid_t pid=fork();if(pid==0){args.Exec();}returntrue;}voidSetupCopyAndUpdate(constunique_fd&profile_fd,constunique_fd&reference_profile_fd,constunique_fd&apk_fd,conststd::string&dex_location){SetupArgs(...);}voidSetupArgs(conststd::vector<T>&profile_fds,constunique_fd&reference_profile_fd,conststd::vector<U>&apk_fds,conststd::vector<std::string>&dex_locations,bool copy_and_update,bool for_snapshot,bool for_boot_image){constchar*profman_bin=select_execution_binary("/profman");if(reference_profile_fd!=-1){AddArg("--reference-profile-file-fd="+std::to_string(reference_profile_fd.get()));}for(constT&fd:profile_fds){AddArg("--profile-file-fd="+std::to_string(fd.get()));}for(constU&fd:apk_fds){AddArg("--apk-fd="+std::to_string(fd.get()));}for(conststd::string&dex_location:dex_locations){AddArg("--dex-locatinotallow="+dex_location);}...}

实际上,就是执行了下面的profman命令:

./profman--reference-profile-file-fd=9\--profile-file-fd=10--apk-fd=11\--dex-locatinotallow=/data/app/com.ss.android.article.video-4-JZaMrtO7n_kFe4kbhBBA==/base.apk \--copy-and-update-profile-key

reference-profile-file-fd指向/data/misc/profile/ref/$package/primary.prof文件,记录当前apk版本的热点方法,最终baseline.prof保存的热点方法信息需要写入到reference-profile文件。

profman二进制程序的代码如下:

classProfManfinal{public:voidParseArgs(intargc,char**argv){MemMap::Init();for(inti=0;i<argc;++i){if(StartsWith(option,"--profile-file=")){profile_files_.push_back(std::string(option.substr(strlen("--profile-file="))));}elseif(StartsWith(option,"--profile-file-fd=")){ParseFdForCollection(raw_option,"--profile-file-fd=",&profile_files_fd_);}elseif(StartsWith(option,"--dex-locatinotallow=")){dex_locations_.push_back(std::string(option.substr(strlen("--dex-locatinotallow="))));}elseif(StartsWith(option,"--apk-fd=")){ParseFdForCollection(raw_option,"--apk-fd=",&apks_fd_);}elseif(StartsWith(option,"--apk=")){apk_files_.push_back(std::string(option.substr(strlen("--apk="))));}...}staticintprofman(intargc,char**argv){ProfManprofman;// Parse arguments. Argument mistakes will lead to exit(EXIT_FAILURE) in UsageError.profman.ParseArgs(argc,argv);// Initialize MemMap for ZipArchive::OpenFromFd.MemMap::Init();...// Process profile information and assess if we need to do a profile guided compilation.// This operation involves I/O.returnprofman.ProcessProfiles();}

可以看到最后一行调用到profman的ProcessProfiles方法,它里面调用了ProfileAssistant.cpp#ProcessProfilesInternal[https://cs.android.com/android/platform/superproject/+/master:art/profman/profile_assistant.cc;l=30?q=ProcessProfilesInternal],核心代码如下:

ProfmanResult::ProcessingResultProfileAssistant::ProcessProfilesInternal(conststd::vector<ScopedFlock>&profile_files,constScopedFlock&reference_profile_file,constProfileCompilationInfo::ProfileLoadFilterFn&filter_fn,constOptions&options){ProfileCompilationInfoinfo(options.IsBootImageMerge());//步骤1:Load the reference profile.if(!info.Load(reference_profile_file->Fd(),true,filter_fn)){returnProfmanResult::kErrorBadProfiles;}// Store the current state of the reference profile before merging with the current profiles.uint32_t number_of_methods=info.GetNumberOfMethods();uint32_t number_of_classes=info.GetNumberOfResolvedClasses();//步骤2:Merge all current profiles.for(size_t i=0;i<profile_files.size();i++){ProfileCompilationInfocur_info(options.IsBootImageMerge());if(!cur_info.Load(profile_files[i]->Fd(),/*merge_classes=*/true,filter_fn)){returnProfmanResult::kErrorBadProfiles;}if(!info.MergeWith(cur_info)){returnProfmanResult::kErrorBadProfiles;}}// 如果新增方法/类没有达到阈值,则跳过if(((info.GetNumberOfMethods()-number_of_methods)<min_change_in_methods_for_compilation)&&((info.GetNumberOfResolvedClasses()-number_of_classes)<min_change_in_classes_for_compilation)){returnkSkipCompilation;}...//步骤3:We were successful in merging all profile information. Update the reference profile....if(!info.Save(reference_profile_file->Fd())){returnProfmanResult::kErrorIO;}returnoptions.IsForceMerge()?ProfmanResult::kSuccess:ProfmanResult::kCompile;}

这里首先通过ProfileCompilationInfo的load方法,读取reference_profile二进制文件序列化加载到内存。再调用MergeWith方法将cur_profile二进制文件(也就是apk内的baseline.prof)合并到reference_profile文件中,最后调用Save方法保存。

再来看下ProfileCompilationInfo的类结构,可以发现与前面编译期处理提到的ArtProfile序列化格式是一致的。

参考链接:https://cs.android.com/android-studio/platform/tools/base/+/mirror-goog-studio-main:profgen/profgen/src/main/kotlin/com/android/tools/profgen/ArtProfileSerializer.kt

//art/libprofile/profile/profile_compilation_info.h/**
 * Profile information in a format suitable to be queried by the compiler and
 * performing profile guided compilation.
 * It is a serialize-friendly format based on information collected by the
 * interpreter (ProfileInfo).
 * Currently it stores only the hot compiled methods.
 */classProfileCompilationInfo{public:staticconstuint8_t kProfileMagic[];staticconstuint8_t kProfileVersion[];staticconstuint8_t kProfileVersionForBootImage[];staticconstcharkDexMetadataProfileEntry[];staticconstexpr size_t kProfileVersionSize=4;staticconstexpr uint8_t kIndividualInlineCacheSize=5;...}

dex优化

分析完prof二进制文件处理流程之后,接着再来看dex优化部分。主要流程如下图所示:


图片


dex优化的入口函数PackageDexOptimizer.java#performDexOptLI,跟踪代码可以发现最终是通过调用dex2oat二进制程序:

//dexopt.cppintdexopt(constchar*dex_path,uid_t uid,constchar*pkgname,constchar*instruction_set,intdexopt_needed,constchar*oat_dir,intdexopt_flags,constchar*compiler_filter,constchar*volume_uuid,constchar*class_loader_context,constchar*se_info,bool downgrade,inttarget_sdk_version,constchar*profile_name,constchar*dex_metadata_path,constchar*compilation_reason,std::string*error_msg,/* out */bool*completed){...RunDex2Oatrunner(dex2oat_bin,execv_helper.get());runner.Initialize(...);bool cancelled=false;pid_t pid=dexopt_status_->check_cancellation_and_fork(&cancelled);if(cancelled){*completed=false;return0;}if(pid==0){//设置schedpolicy,设置为后台线程SetDex2OatScheduling(boot_complete);//执行dex2oat命令runner.Exec(DexoptReturnCodes::kDex2oatExec);}else{//父进程等待dex2oat子进程执行完,超时时间9.5分钟.intres=wait_child_with_timeout(pid,kLongTimeoutMs);if(res==0){LOG(VERBOSE)<<"DexInv: --- END '"<<dex_path<<"' (success) ---";}else{//dex2oat执行失败}}// dex2oat ran successfully, so profile is safe to keep.reference_profile.DisableCleanup();return0;}

实际上是执行了如下命令:

/apex/com.android.runtime/bin/dex2oat \--input-vdex-fd=-1--output-vdex-fd=11\--resolve-startup-const-strings=true\--max-image-block-size=524288--compiler-filter=speed-profile--profile-file-fd=14\--classpath-dir=/data/app/com.ss.android.article.video-4-JZaMrtO7n_kFe4kbhBBA==\--class-loader-context=PCL[]{PCL[/system/framework/org.apache.http.legacy.jar]}\--generate-mini-debug-info--compact-dex-level=none--dm-fd=15\--compilation-reasnotallow=install-dm

常规安装时不会带上dm-fd和install-dm参数,所以不会触发baseline profile相关优化。

dex2oat用于将dex字节码编译成本地机器码,相关的编译流程如下代码:

staticdex2oat::ReturnCodeDex2oat(intargc,char**argv){TimingLoggertimings("compiler",false,false);// 解析参数dex2oat->ParseArgs(argc,argv);art::MemMap::Init();// 加载profile热点方法文件if(dex2oat->HasProfileInput()){if(!dex2oat->LoadProfile()){returndex2oat::ReturnCode::kOther;}}//打开输入文件dex2oat->OpenFile();//准备de2oat环境,包括启动runtime、加载boot class pathdex2oat::ReturnCodesetup_code=dex2oat->Setup();//检查profile热点方法是否被加载到内存,并做crc校验if(dex2oat->DoProfileGuidedOptimizations()){//校验profile_compilation_info_中dex的crc与apk中dex的crc是否一致dex2oat->VerifyProfileData();}...//正式开始编译dex2oat::ReturnCoderesult=DoCompilation(*dex2oat);...returnresult;}

这个流程包含:

  • 解析命令行传入的参数

  • 调用LoadProfile()加载profile热点方法文件,保存到profile_compilation_info_成员变量中

  • 准备dex2oat环境,包括启动unstarted runtime、加载boot class path

  • profile相关校验,主要检查profile_compilation_info_中的dex的crc与apk中dex的crc是否一致,方法数是否一致

  • 调用DoCompilation正式开始编译

LoadProfile方法加载profile热点方法文件如下代码:

boolLoadProfile(){//初始化profile热点方法的内存对象:profile_compilation_info_profile_compilation_info_.reset(newProfileCompilationInfo());//读取reference profile文件列表// Dex2oat only uses the reference profile and that is not updated concurrently by the app or// other processes. So we don't need to lock (as we have to do in profman or when writing the// profile info).std::vector<std::unique_ptr<File>>profile_files;if(!profile_file_fds_.empty()){for(intfd:profile_file_fds_){profile_files.push_back(std::make_unique<File>(DupCloexec(fd)));}}...//依次加载到profile_compilation_info_中for(conststd::unique_ptr<File>&profile_file:profile_files){if(!profile_compilation_info_->Load(profile_file->Fd())){returnfalse;}}returntrue;}

LoadProfile方法,将之前生成的profile文件加载到内存,保存到profile_compilation_info_变量中。

接着调用Compile方法完成odex文件的编译生成,如下代码:

// Set up and create the compiler driver and then invoke it to compile all the dex files.jobjectCompile()REQUIRES(!Locks::mutator_lock_){ClassLinker*constclass_linker=Runtime::Current()->GetClassLinker();TimingLogger::ScopedTimingt("dex2oat Compile",timings_);...compiler_options_->profile_compilation_info_=profile_compilation_info_.get();driver_.reset(newCompilerDriver(compiler_options_.get(),verification_results_.get(),compiler_kind_,thread_count_,swap_fd_));driver_->PrepareDexFilesForOatFile(timings_);returnCompileDexFiles(dex_files);}

profile_compilation_info_作为参数传给了CompilerDriver,在之后的编译过程中将用来判断是否编译某个方法和机器码重排。

CompilerDriver::Compile方法开始编译dex字节码,代码如下:

voidCompilerDriver::Compile(jobject class_loader,conststd::vector<constDexFile*>&dex_files,TimingLogger*timings){for(constDexFile*dex_file:dex_files){CompileDexFile(this,class_loader,*dex_file,dex_files,"Compile Dex File Quick",CompileMethodQuick);}}staticvoidCompileMethodQuick(...){auto quick_fn=[profile_index](...){CompiledMethod*compiled_method=nullptr;if((access_flags&kAccNative)!=0){//jni方法编译...}elseif((access_flags&kAccAbstract)!=0){// Abstract methods don't have code.}elseif(annotations::MethodIsNeverCompile(dex_file,dex_file.GetClassDef(class_def_idx),method_idx)){// Method is annotated with @NeverCompile and should not be compiled.}else{constCompilerOptions&compiler_options=driver->GetCompilerOptions();constVerificationResults*results=driver->GetVerificationResults();MethodReferencemethod_ref(&dex_file,method_idx);// Don't compile class initializers unless kEverything.bool compile=(compiler_options.GetCompilerFilter()==CompilerFilter::kEverything)||((access_flags&kAccConstructor)==0)||((access_flags&kAccStatic)==0);// Check if it's an uncompilable method found by the verifier.compile=compile&&!results->IsUncompilableMethod(method_ref);// Check if we should compile based on the profile.compile=compile&&ShouldCompileBasedOnProfile(compiler_options,profile_index,method_ref);if(compile){compiled_method=driver->GetCompiler()->Compile(...);}}returncompiled_method;};CompileMethodHarness(self,driver,code_item,access_flags,invoke_type,class_def_idx,class_loader,dex_file,dex_cache,quick_fn);}

在CompileMethodQuick方法中可以看到针对不同的方法(jni方法、虚方法、构造函数等)有不同的处理方式,常规方法会通过ShouldCompileBasedOnProfile来判断某个method是否需要被编译。

具体判断条件如下:

// Checks whether profile guided compilation is enabled and if the method should be compiled// according to the profile file.staticboolShouldCompileBasedOnProfile(constCompilerOptions&compiler_options,ProfileCompilationInfo::ProfileIndexTypeprofile_index,MethodReferencemethod_ref){if(profile_index==ProfileCompilationInfo::MaxProfileIndex()){// No profile for this dex file. Check if we're actually compiling based on a profile.if(!CompilerFilter::DependsOnProfile(compiler_options.GetCompilerFilter())){returntrue;}// Profile-based compilation without profile for this dex file. Do not compile the method.returnfalse;}else{constProfileCompilationInfo*profile_compilation_info=compiler_options.GetProfileCompilationInfo();// Compile only hot methods, it is the profile saver's job to decide// what startup methods to mark as hot.bool result=profile_compilation_info->IsHotMethod(profile_index,method_ref.index);if(kDebugProfileGuidedCompilation){LOG(INFO)<<"[ProfileGuidedCompilation] "<<(result?"Compiled":"Skipped")<<" method:"<<method_ref.PrettyMethod(true);}returnresult;}}

可以看到是依据profile_compilation_info_是否命中hotmethod来判断。我们把编译日志打开,可以看到具体哪些方法被编译,哪些方法被跳过,如下图所示,这与我们配置的profile是一致的。


图片


机器码生成的实现在CodeGenerator类中,代码如下,具体细节将不再展开。

//art/compiler/optimizing/code_generator.ccvoidCodeGenerator::Compile(CodeAllocator*allocator){InitializeCodeGenerationData();HGraphVisitor*instruction_visitor=GetInstructionVisitor();GetStackMapStream()->BeginMethod(...);size_t frame_start=GetAssembler()->CodeSize();GenerateFrameEntry();if(disasm_info_!=nullptr){disasm_info_->SetFrameEntryInterval(frame_start,GetAssembler()->CodeSize());}for(size_t e=block_order_->size();current_block_index_<e;++current_block_index_){HBasicBlock*block=(*block_order_)[current_block_index_];Bind(block);MaybeRecordNativeDebugInfo(/* instructinotallow= */nullptr,block->GetDexPc());for(HInstructionIteratorit(block->GetInstructions());!it.Done();it.Advance()){HInstruction*current=it.Current();DisassemblyScopedisassembly_scope(current,*this);current->Accept(instruction_visitor);}}GenerateSlowPaths();if(graph_->HasTryCatch()){RecordCatchBlockInfo();}// Finalize instructions in assember;Finalize(allocator);GetStackMapStream()->EndMethod(GetAssembler()->CodeSize());}

另外,profile_compilation_info_也会影响机器码重排,我们知道系统在通过IO加载文件的时候,一般都是按页维度来加载的(一般等于4KB),热点代码重排在一起,可以减少IO读取的次数,从而提升性能。

odex文件的机器码布局部分由OatWriter类实现,声明代码如下:

classOatWriter{public:OatWriter(constCompilerOptions&compiler_options,constVerificationResults*verification_results,TimingLogger*timings,ProfileCompilationInfo*info,CompactDexLevelcompact_dex_level);...// Profile info used to generate new layout of files.ProfileCompilationInfo*profile_compilation_info_;// Compact dex level that is generated.CompactDexLevelcompact_dex_level_;usingOrderedMethodList=std::vector<OrderedMethodData>;...

从中可以看到profile_compilation_info_会被OatWriter类用到,用于生成odex机器码的布局。

具体代码如下:

// Visit every compiled method in order to determine its order within the OAT file.// Methods from the same class do not need to be adjacent in the OAT code.classOatWriter::LayoutCodeMethodVisitorfinal:publicOatDexMethodVisitor{public:LayoutCodeMethodVisitor(OatWriter*writer,size_t offset):OatDexMethodVisitor(writer,offset),profile_index_(ProfileCompilationInfo::MaxProfileIndex()),profile_index_dex_file_(nullptr){}boolStartClass(constDexFile*dex_file,size_t class_def_index)final{// Update the cached `profile_index_` if needed. This happens only once per dex file// because we visit all classes in a dex file together, so mark that as `UNLIKELY`.if(UNLIKELY(dex_file!=profile_index_dex_file_)){if(writer_->profile_compilation_info_!=nullptr){profile_index_=writer_->profile_compilation_info_->FindDexFile(*dex_file);}profile_index_dex_file_=dex_file;}returnOatDexMethodVisitor::StartClass(dex_file,class_def_index);}boolVisitMethod(size_t class_def_method_index,constClassAccessor::Method&method){OatClass*oat_class=&writer_->oat_classes_[oat_class_index_];CompiledMethod*compiled_method=oat_class->GetCompiledMethod(class_def_method_index);if(HasCompiledCode(compiled_method)){// Determine the `hotness_bits`, used to determine relative order// for OAT code layout when determining binning.uint32_t method_index=method.GetIndex();MethodReferencemethod_ref(dex_file_,method_index);uint32_t hotness_bits=0u;if(profile_index_!=ProfileCompilationInfo::MaxProfileIndex()){ProfileCompilationInfo*pci=writer_->profile_compilation_info_;// Note: Bin-to-bin order does not matter. If the kernel does or does not read-ahead// any memory, it only goes into the buffer cache and does not grow the PSS until the// first time that memory is referenced in the process.hotness_bits=(pci->IsHotMethod(profile_index_,method_index)?kHotBit:0u)|(pci->IsStartupMethod(profile_index_,method_index)?kStartupBit:0u)}}OrderedMethodDatamethod_data={hotness_bits,oat_class,compiled_method,method_ref,...};ordered_methods_.push_back(method_data);}returntrue;}

在LayoutCodeMethodVisitor类中,根据profile_compilation_info_指定的热点方法的FLAG,判断是否打开hotness_bits标志位。热点方法会一起被重排在odex文件靠前的位置。

小结一下,在系统安装app阶段,会读取apk中baselineprofile文件,经过porfman根据当前系统版本做一定转换并序列化到本地的reference_profile路径下,再通过dexoat编译热点方法为本地机器码并通过代码重排提升性能。

厂商合作

Baseline Profile安装时优化需要Google Play支持,但国内手机由于没有Google Play,无法在安装期做实现优化效果。为此,我们协同抖音与小米、华为等主流厂商建立了合作,共同推进Baseline Profile安装时优化在国内环境的落地。具体的合作方式是:

  • 我们通过编译期改造,提供带Baseline Profile的APK给到厂商验证联调。

  • 厂商具体的优化策略会综合考量安装时长、dex2oat消耗资源情况而定,比如先用默认策略安装apk,再后台异步执行Baseline Profile编译。

  • 最后通过Google提供的初步显示所用时间 (TTID) 来验证优化效果(TTID指标用于测量应用生成第一帧所用的时间,包括进程初始化、activity 创建以及显示第一帧。)

参考链接

https://developer.android.com/topic/performance/vitals/launch-time?hl=zh-cn

在与厂商联调的过程中,我们解决了各种问题,其中包括有一个资源压缩方式错误。具体错误信息如下:

java.io.FileNotFoundException:Thisfile can not be opened as a file descriptor;it is probably compressed

原来安卓系统要求apk内的baseline.prof二进制是不压缩格式的。我们可以用unzip -v来检验文件是否未被压缩,Defl标志表示压缩,Stored标志表示未压缩。


图片


我们可以在打包流程中指定其为STORED格式,即不压缩。

privatevoidwriteNoCompress(@NonNullJarEntryentry,@NonNullInputStreamfrom)throwsIOException{byte[]bytes=newbyte[from.available()];from.read(bytes);entry.setMethod(JarEntry.STORED);entry.setSize(bytes.length);CRC32crc32=newCRC32();crc32.update(bytes,0,bytes.length);entry.setCrc(crc32.getValue());setEntryAttributes(entry);jarOutputStream.putNextEntry(entry);jarOutputStream.write(bytes,0,bytes.length);jarOutputStream.closeEntry();}

改完之后我们再检查一下文件是否被压缩。


图片


baseline.prof二进制是不压缩对包体积影响比较小,因为这个文件大部分都是int类型的methodid。经测试,7万+热点方法文件,生成baseline.prof二进制文件62KB,压缩率只有0.1%;如果通过通配符配置,压缩率在5%左右。

一般应用商店下载安装包时在网络传输过程中做了(压缩)https://zh.wikipedia.org/wiki/HTTP%E5%8E%8B%E7%BC%A9处理,这种情况不压缩处理基本不影响包大小,同时不压缩处理也能避免解压缩带来的耗时。

优化效果

在自测中,我们可以通过下面的方式通过install-multiple命令安装APK。

#UnziptheReleaseAPK first
unzip release.apk
#Createa ZIP archive
cp assets/dexopt/baseline.prof primary.prof
cp assets/dexopt/baseline.profm primary.profm
#Createan archive
zip-r release.dm primary.prof primary.profm
#InstallAPK+Profiletogether
adb install-multiple release.apk release.dm

在厂商测试中通过下面的命令测试冷启动耗时

PACKAGE_NAME=com.ss.android.article.video
adb shell am start-activity-W-n $PACKAGE_NAME/.SplashActivity|grep"TotalTime"

冷启动Activity耗时比较

未优化

已优化

优化率

荣耀Android11

950ms

884ms

6.9%

小米Android13

821ms

720ms

12.3%

可以看到,在开启Baseline Profile优化之后,首装冷启动(TTID)耗时减少约10%左右,为新用户的启动速度体验带来了极大的提升。

参考文章

  • Android 端内数据状态同步方案VM-Mapping

  • 开源 | Scene:Android 开源页面导航和组合框架

团队介绍

我们是字节跳动西瓜视频客户端团队,专注于西瓜视频 App 的开发和基础技术建设,在客户端架构、性能、稳定性、编译构建、研发工具等方向都有投入。如果你也想一起攻克技术难题,迎接更大的技术挑战,欢迎点击阅读原文,或者投递简历到xiaolin.gan@bytedance.com。

最 Nice 的工作氛围和成长机会,福利与机遇多多,在北上杭三地均有职位,欢迎加入西瓜视频客户端团队 !

15    2023-06-02 14:18:55    Android 西瓜视频