Baseline Profile 安裝時優(yōu)化在西瓜視頻的實踐
背景
在Android 5,Google采用的策略是在應用安裝期間對APP的全量DEX進行AOT優(yōu)化。AOT優(yōu)化(Ahead of time),就是在APP運行前就把DEX字節(jié)碼編譯成本地機器碼。雖然運行效率相比DEX解釋執(zhí)行有了大幅提高,但由于是全量AOT,就會導致用戶需要等待較長的時間才能打開應用,對于磁盤空間的占用也急劇增大。
于是,為了避免過早的資源占用,從Android 7開始便不再進行全量AOT,而是JIT+AOT的混合編譯模式。JIT(Just in time),就是即時優(yōu)化,也就是在APP運行過程中,實時地把DEX字節(jié)碼編譯成本地機器碼。具體方式是,在APP運行時分析運行過的熱代碼,然后在設備空閑時觸發(fā)AOT,在下次運行前預編譯熱代碼,提升后續(xù)APP運行效率。
但是熱代碼代碼收集需要比較長周期,在APP升級覆蓋安裝之后,原有的預編譯的熱代碼失效,需要再走一遍運行時分析、空閑時AOT的流程。在單周迭代的研發(fā)模式下問題尤為明顯。
因此,從Android 9 開始,Google推出了Cloud Profiles技術。它的原理是,在部分先安裝APK的用戶手機上,Google Play Store收集到熱點代碼,然后上傳到云端并聚合。這樣,對于后面安裝的用戶,Play Store會下發(fā)熱點代碼配置進行預編譯,這些用戶就不需要進行運行時分析,大大提前了優(yōu)化時機。不過,這個收集聚合下發(fā)過程需要幾天時間,大部分用戶還是沒法享受到這個優(yōu)化。
最終,在2022年Google推出了 Baseline Profiles (https://developer.android.com/topic/performance/baselineprofiles/overview?hl=zh-cn)技術。它允許開發(fā)者內(nèi)置自己定義的熱點代碼配置文件。在APP安裝期間,系統(tǒng)提前預編譯熱點代碼,大幅提升APP運行效率。
不過,Google官方的Baseline Profiles存在以下局限性:
- Baseline Profile 需要使用 AGP 7 及以上的版本,公司內(nèi)各大APP的版本都還比較低,短期內(nèi)并不可用
- 安裝時優(yōu)化依賴Google Play,國內(nèi)無法使用
為此,我們開發(fā)了一套定制化的Baseline Profiles優(yōu)化方案,可以適用于全版本AGP。同時通過與國內(nèi)主流廠商合作,推進支持了安裝時優(yōu)化生效。
方案探索與實現(xiàn)
我們先來看一下官方Baseline Profile安裝時優(yōu)化的流程:
這里面主要包含3個步驟:
- 熱點方法收集,通過本地運行設備或者人工配置,得到可讀格式的基準配置文本文件(baseline-prof.txt)
- 編譯期處理,將基準配置文本文件轉換成二進制文件,打包至apk內(nèi)(baseline.prof和baseline.profm),另外Google Play服務端還會將云端profile與baseline.prof聚合處理。
- 安裝時,系統(tǒng)會解析apk內(nèi)的baseline.prof二進制文件,根據(jù)版本號,做一些轉換后,提前預編譯指定的熱點代碼為機器碼。
熱點方法收集
官方文檔(https://developer.android.com/topic/performance/baselineprofiles/create-baselineprofile)提到使用Jetpack Macrobenchmark庫(https://developer.android.com/macrobenchmark) 和 BaselineProfileRule
自動收集熱點方法。通過在Android Studio中引入Benchmark module,需要編寫相應的Rule觸發(fā)打包、測試等流程。
從下面源碼可以看到,最終是通過profman命令可以收集到app運行過程中的熱點方法。
private fun profmanGetProfileRules(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 }
}
...
return builder.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文件內(nèi)容如下:
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)Z
HSPLorg/android/spdy/SpdyAgent;->InvlidCharJudge([B[B)V
Lanet/channel/e/a$b;
Lcom/bytedance/alliance/services/impl/c;
...
這些規(guī)則采用兩種形式,分別指明方法和類。方法的規(guī)則如下所示:
[FLAGS][CLASS_DESCRIPTOR]->[METHOD_SIGNATURE]
FLAGS表示 H
、S
和 P
中的一個或多個字符,用于指示相應方法在啟動類型方面應標記為 Hot
、Startup
還是 Post Startup
:
- 帶有
H
標記表示相應方法是一種“熱”方法,這意味著相應方法在應用的整個生命周期內(nèi)會被調(diào)用多次。 - 帶有
S
標記表示相應方法在啟動時被調(diào)用。 - 帶有
P
標記表示相應方法是與啟動無關的熱方法。
類的規(guī)則,則是直接指明類簽名即可:
[CLASS_DESCRIPTOR]
不過這里是可讀的文本格式,后續(xù)還需要進一步轉為二進制才可以被系統(tǒng)識別。
另外,release包導出的是混淆后的符號,需要根據(jù)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內(nèi)附帶的baseline-prof.txt取出來,與base.apk的配置一起合并成完整的基準配置文本文件。
接下來,我們需要把完整的配置文件轉換成baseline.prof二進制文件。具體是由AGP 7.x內(nèi)的 CompileArtProfileTask.kt
實現(xiàn)的 :
/**
* Task that transforms a human readable art profile into a binary form version that can be shipped
* inside an APK or a Bundle.
*/
abstract class CompileArtProfileTask: NonIncrementalTask() {
...
abstract class CompileArtProfileWorkAction:
ProfileAwareWorkAction<CompileArtProfileWorkAction.Parameters>() {
override fun run() {
val diagnostics = Diagnostics {
error -> throw RuntimeException("Error parsing baseline-prof.txt : $error")
}
val humanReadableProfile = HumanReadableProfile(
parameters.mergedArtProfile.get().asFile,
diagnostics
) ?: throw RuntimeException(
"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] does
parameters.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件事:
- 讀取baseline-prof.txt基準配置文本文件,下文用HumanReadableProfile表示
- 將HumanReadableProfile、proguard mapping文件、dex文件作為輸入傳給ArtProfile
- 由ArtProfile生成特定版本格式的baseline.prof二進制文件
ArtProfile類是在profgen子工程內(nèi)實現(xiàn)的,其中有兩個關鍵的方法:
- 構造方法:讀取HumanReadableProfile、proguard mapping文件、dex文件作為參數(shù),構造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開發(fā)一個gradle plugin,在編譯構建流程中插入一個自定義task,將baseline-prof.txt轉換成baseline.prof,并內(nèi)置到apk的asset目錄。
核心代碼如下:
val packageAndroidTask =
variant.variantScope.taskContainer.packageAndroidTask?.get()
packageAndroidTask?.doFirst {
var dexFiles = collectDexFiles(variant.packageApplication.dexFolders)
dexFiles = dexFiles.sortedWith(DexFileComparator())
//基準配置文件的內(nèi)存表示
var hrp = HumanReadableProfile("baseline-prof.txt")
var obfFile: File? = getObfFile(variant, proguardTask)
val apk = Apk(dexFiles, "")
val obf =
if (obfFile != null) ObfuscationMap(obfFile) else ObfuscationMap.Empty
val 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主要包含以下幾個步驟:
- 解壓apk獲取dex列表,按照一定規(guī)則排序(跟Android的打包規(guī)則有關,dex文件名和crc等信息需要和prof二進制文件內(nèi)的對應上)
- 通過ObfuscationMap將baseline-prof.txt文件中的符號轉換成混淆后的符號
- 通過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之后,再來看一下系統(tǒng)在安裝apk時如何處理這個baseline.prof文件(基于Android 13源碼分析)。本地測試通過adb install-multiple release.apk release.dm
命令執(zhí)行安裝,然后通過Android系統(tǒng)包管理子系統(tǒng)進行安裝時優(yōu)化。
Android系統(tǒng)包管理框架分為3層:
- 應用層:應用通過getPackageManager獲取PMS的實例,用于應用的安裝,卸載,更新等操作
- PMS服務層:擁有系統(tǒng)權限,解析并記錄應用的基本信息(應用名稱,數(shù)據(jù)存放路徑、關系管理等),最終通過binder系統(tǒng)層的installd系統(tǒng)服務進行通訊
- Installd系統(tǒng)服務層:擁有root權限,完成最終的apk安裝、dex優(yōu)化
其中處理baseline.prof二進制文件并最終指導編譯生成odex的主要路徑如下:
InstallPackageHelper.java#installPackagesLI
InstallPackageHelper.java#executePostCommitSteps
ArtManagerService.java#prepareAppProfiles
Installer.java#prepareAppProfile
InstalldNativeService.cpp#prepareAppProfile
dexopt.cpp#prepare_app_profile
ProfileAssistant.cpp#ProcessProfilesInternal
PackageDexOptimizer.java#performDexOpt
PackageDexOptimizer.java#performDexOptLI
PackageDexOptimizer.java#dexOptPath
InstalldNativeService.cpp#dexopt
dexopt.cpp#dexopt
dex2oat.cc
在入口installPackagesLI函數(shù)中,經(jīng)過prepare、scan、Reconcile、Commit 四個階段后最終調(diào)用executePostCommitSteps完成apk安裝、prof文件寫入、dexopt優(yōu)化:
private void installPackagesLI(List<InstallRequest> requests) {
//階段1:prepare
prepareResult = preparePackageLI(request.mArgs, request.mInstallResult);
//階段2:scan
final ScanResult result = scanPackageTracedLI(
prepareResult.mPackageToScan, prepareResult.mParseFlags,
prepareResult.mScanFlags, System.currentTimeMillis(),
request.mArgs.mUser, request.mArgs.mAbiOverride);
//階段3:Reconcile
reconciledPackages = ReconcilePackageUtils.reconcilePackages(
reconcileRequest, mSharedLibraries,
mPm.mSettings.getKeySetManagerService(), mPm.mSettings);
//階段4:Commit并安裝
commitRequest = new CommitRequest(reconciledPackages,
mPm.mUserManager.getUserIds());
executePostCommitSteps(commitRequest);
}
executePostCommitSteps中,主要完成prof文件寫入與dex優(yōu)化:
private void executePostCommitSteps(CommitRequest commitRequest) {
for (ReconciledPackage reconciledPkg : commitRequest.mReconciledPackages.values()) {
final AndroidPackage pkg = reconciledPkg.mPkgSetting.getPkg();
final String packageName = pkg.getPackageName();
final String codePath = 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優(yōu)化,在開啟baseline profile優(yōu)化之后compilation-reasnotallow=install-dm
final int compilationReason =
mDexManager.getCompilationReasonForInstallScenario(
reconciledPkg.mInstallArgs.mInstallScenario);
DexoptOptions dexoptOptions =
new DexoptOptions(packageName, compilationReason, dexoptFlags);
if (performDexopt) {
// Compile the layout resources.
if (SystemProperties.getBoolean(PRECOMPILE_LAYOUTS, false)) {
mViewCompiler.compileLayouts(pkg);
}
ScanResult result = 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.
*/
public void prepareAppProfiles(
AndroidPackage pkg, @UserIdInt int user,
boolean updateReferenceProfileContent) {
try {
ArrayMap<String, String> codePathsProfileNames = getPackageProfileNames(pkg);
for (int i = codePathsProfileNames.size() - 1; i >= 0; i--) {
String codePath = codePathsProfileNames.keyAt(i);
String profileName = codePathsProfileNames.valueAt(i);
String dexMetadataPath = 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) {
File dexMetadata = DexMetadataHelper.findDexMetadataForFile(new File(codePath));
dexMetadataPath = dexMetadata == null ? null : dexMetadata.getAbsolutePath();
}
synchronized (mInstaller) {
boolean result = mInstaller.prepareAppProfile(pkg.getPackageName(), user, appId,
profileName, codePath, dexMetadataPath);
}
}
} catch (InstallerException e) {
}
}
其中dexMetadata是后綴為.dm的壓縮文件,內(nèi)部包含primary.prof、primary.profm文件,apk的baseline.prof、baseline.profm會在安裝階段轉為成dm文件。
mInstaller.prepareAppProfile
最終會調(diào)用到dexopt.cpp#prepare_app_profile
中,通過fork一個子進程執(zhí)行profman二進制程序,將dm文件、reference_profile文件(位于設備上固定路徑,存儲匯總的熱點方法)、apk文件作為參數(shù)輸入:
//frameworks/native/cmds/installd/dexopt.cpp
bool prepare_app_profile(const std::string& package_name,
userid_t user_id,
appid_t app_id,
const std::string& profile_name,
const std::string& code_path,
const std::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_fd dex_metadata_fd(TEMP_FAILURE_RETRY(
open(dex_metadata->c_str(), O_RDONLY | O_NOFOLLOW)));
unique_fd apk_fd(TEMP_FAILURE_RETRY(open(code_path.c_str(), O_RDONLY | O_NOFOLLOW)));
RunProfman args;
args.SetupCopyAndUpdate(dex_metadata_fd,
ref_profile_fd,
apk_fd,
code_path);
pid_t pid = fork();
if (pid == 0) {
args.Exec();
}
return true;
}
void SetupCopyAndUpdate(const unique_fd& profile_fd,
const unique_fd& reference_profile_fd,
const unique_fd& apk_fd,
const std::string& dex_location) {
SetupArgs(...);
}
void SetupArgs(const std::vector<T>& profile_fds,
const unique_fd& reference_profile_fd,
const std::vector<U>& apk_fds,
const std::vector<std::string>& dex_locations,
bool copy_and_update,
bool for_snapshot,
bool for_boot_image) {
const char* profman_bin = select_execution_binary("/profman");
if (reference_profile_fd != -1) {
AddArg("--reference-profile-file-fd=" + std::to_string(reference_profile_fd.get()));
}
for (const T& fd : profile_fds) {
AddArg("--profile-file-fd=" + std::to_string(fd.get()));
}
for (const U& fd : apk_fds) {
AddArg("--apk-fd=" + std::to_string(fd.get()));
}
for (const std::string& dex_location : dex_locations) {
AddArg("--dex-locatinotallow=" + dex_location);
}
...
}
實際上,就是執(zhí)行了下面的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二進制程序的代碼如下:
class ProfMan final {
public:
void ParseArgs(int argc, char **argv) {
MemMap::Init();
for (int i = 0; i < argc; ++i) {
if (StartsWith(option, "--profile-file=")) {
profile_files_.push_back(std::string(option.substr(strlen("--profile-file="))));
} else if (StartsWith(option, "--profile-file-fd=")) {
ParseFdForCollection(raw_option, "--profile-file-fd=", &profile_files_fd_);
} else if (StartsWith(option, "--dex-locatinotallow=")) {
dex_locations_.push_back(std::string(option.substr(strlen("--dex-locatinotallow="))));
} else if (StartsWith(option, "--apk-fd=")) {
ParseFdForCollection(raw_option, "--apk-fd=", &apks_fd_);
} else if (StartsWith(option, "--apk=")) {
apk_files_.push_back(std::string(option.substr(strlen("--apk="))));
}
...
}
static int profman(int argc, char** argv) {
ProfMan profman;
// 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.
return profman.ProcessProfiles();
}
可以看到最后一行調(diào)用到profman的ProcessProfiles方法,它里面調(diào)用了ProfileAssistant.cpp#ProcessProfilesInternal[https://cs.android.com/android/platform/superproject/+/master:art/profman/profile_assistant.cc;l=30?q=ProcessProfilesInternal],核心代碼如下:
ProfmanResult::ProcessingResult ProfileAssistant::ProcessProfilesInternal(
const std::vector<ScopedFlock>& profile_files,
const ScopedFlock& reference_profile_file,
const ProfileCompilationInfo::ProfileLoadFilterFn& filter_fn,
const Options& options) {
ProfileCompilationInfo info(options.IsBootImageMerge());
//步驟1:Load the reference profile.
if (!info.Load(reference_profile_file->Fd(), true, filter_fn)) {
return ProfmanResult::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++) {
ProfileCompilationInfo cur_info(options.IsBootImageMerge());
if (!cur_info.Load(profile_files[i]->Fd(), /*merge_classes=*/ true, filter_fn)) {
return ProfmanResult::kErrorBadProfiles;
}
if (!info.MergeWith(cur_info)) {
return ProfmanResult::kErrorBadProfiles;
}
}
// 如果新增方法/類沒有達到閾值,則跳過
if (((info.GetNumberOfMethods() - number_of_methods) < min_change_in_methods_for_compilation)
&& ((info.GetNumberOfResolvedClasses() - number_of_classes) < min_change_in_classes_for_compilation)) {
return kSkipCompilation;
}
...
//步驟3:We were successful in merging all profile information. Update the reference profile.
...
if (!info.Save(reference_profile_file->Fd())) {
return ProfmanResult::kErrorIO;
}
return options.IsForceMerge() ? ProfmanResult::kSuccess : ProfmanResult::kCompile;
}
這里首先通過ProfileCompilationInfo的load方法,讀取reference_profile二進制文件序列化加載到內(nèi)存。再調(diào)用MergeWith方法將cur_profile二進制文件(也就是apk內(nèi)的baseline.prof)合并到reference_profile文件中,最后調(diào)用Save方法保存。
再來看下ProfileCompilationInfo的類結構,可以發(fā)現(xiàn)與前面編譯期處理提到的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.
*/
class ProfileCompilationInfo {
public:
static const uint8_t kProfileMagic[];
static const uint8_t kProfileVersion[];
static const uint8_t kProfileVersionForBootImage[];
static const char kDexMetadataProfileEntry[];
static constexpr size_t kProfileVersionSize = 4;
static constexpr uint8_t kIndividualInlineCacheSize = 5;
...
}
dex優(yōu)化
分析完prof二進制文件處理流程之后,接著再來看dex優(yōu)化部分。主要流程如下圖所示:
dex優(yōu)化的入口函數(shù)PackageDexOptimizer.java#performDexOptLI
,跟蹤代碼可以發(fā)現(xiàn)最終是通過調(diào)用dex2oat二進制程序:
//dexopt.cpp
int dexopt(const char* dex_path, uid_t uid, const char* pkgname, const char* instruction_set,
int dexopt_needed, const char* oat_dir, int dexopt_flags, const char* compiler_filter,
const char* volume_uuid, const char* class_loader_context, const char* se_info,
bool downgrade, int target_sdk_version, const char* profile_name,
const char* dex_metadata_path, const char* compilation_reason, std::string* error_msg,
/* out */ bool* completed) {
...
RunDex2Oat runner(dex2oat_bin, execv_helper.get());
runner.Initialize(...);
bool cancelled = false;
pid_t pid = dexopt_status_->check_cancellation_and_fork(&cancelled);
if (cancelled) {
*completed = false;
return 0;
}
if (pid == 0) {
//設置schedpolicy,設置為后臺線程
SetDex2OatScheduling(boot_complete);
//執(zhí)行dex2oat命令
runner.Exec(DexoptReturnCodes::kDex2oatExec);
} else {
//父進程等待dex2oat子進程執(zhí)行完,超時時間9.5分鐘.
int res = wait_child_with_timeout(pid, kLongTimeoutMs);
if (res == 0) {
LOG(VERBOSE) << "DexInv: --- END '" << dex_path << "' (success) ---";
} else {
//dex2oat執(zhí)行失敗
}
}
// dex2oat ran successfully, so profile is safe to keep.
reference_profile.DisableCleanup();
return 0;
}
實際上是執(zhí)行了如下命令:
/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
常規(guī)安裝時不會帶上dm-fd和install-dm參數(shù),所以不會觸發(fā)baseline profile相關優(yōu)化。
dex2oat用于將dex字節(jié)碼編譯成本地機器碼,相關的編譯流程如下代碼:
static dex2oat::ReturnCode Dex2oat(int argc, char** argv) {
TimingLogger timings("compiler", false, false);
// 解析參數(shù)
dex2oat->ParseArgs(argc, argv);
art::MemMap::Init();
// 加載profile熱點方法文件
if (dex2oat->HasProfileInput()) {
if (!dex2oat->LoadProfile()) {
return dex2oat::ReturnCode::kOther;
}
}
//打開輸入文件
dex2oat->OpenFile();
//準備de2oat環(huán)境,包括啟動runtime、加載boot class path
dex2oat::ReturnCode setup_code = dex2oat->Setup();
//檢查profile熱點方法是否被加載到內(nèi)存,并做crc校驗
if (dex2oat->DoProfileGuidedOptimizations()) {
//校驗profile_compilation_info_中dex的crc與apk中dex的crc是否一致
dex2oat->VerifyProfileData();
}
...
//正式開始編譯
dex2oat::ReturnCode result = DoCompilation(*dex2oat);
...
return result;
}
這個流程包含:
- 解析命令行傳入的參數(shù)
- 調(diào)用LoadProfile()加載profile熱點方法文件,保存到profile_compilation_info_成員變量中
- 準備dex2oat環(huán)境,包括啟動unstarted runtime、加載boot class path
- profile相關校驗,主要檢查profile_compilation_info_中的dex的crc與apk中dex的crc是否一致,方法數(shù)是否一致
- 調(diào)用DoCompilation正式開始編譯
LoadProfile方法加載profile熱點方法文件如下代碼:
bool LoadProfile() {
//初始化profile熱點方法的內(nèi)存對象:profile_compilation_info_
profile_compilation_info_.reset(new ProfileCompilationInfo());
//讀取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 (int fd : profile_file_fds_) {
profile_files.push_back(std::make_unique<File>(DupCloexec(fd)));
}
}
...
//依次加載到profile_compilation_info_中
for (const std::unique_ptr<File>& profile_file : profile_files) {
if (!profile_compilation_info_->Load(profile_file->Fd())) {
return false;
}
}
return true;
}
LoadProfile方法,將之前生成的profile文件加載到內(nèi)存,保存到profile_compilation_info_變量中。
接著調(diào)用Compile方法完成odex文件的編譯生成,如下代碼:
// Set up and create the compiler driver and then invoke it to compile all the dex files.
jobject Compile() REQUIRES(!Locks::mutator_lock_) {
ClassLinker* const class_linker = Runtime::Current()->GetClassLinker();
TimingLogger::ScopedTiming t("dex2oat Compile", timings_);
...
compiler_options_->profile_compilation_info_ = profile_compilation_info_.get();
driver_.reset(new CompilerDriver(compiler_options_.get(),
verification_results_.get(),
compiler_kind_,
thread_count_,
swap_fd_));
driver_->PrepareDexFilesForOatFile(timings_);
return CompileDexFiles(dex_files);
}
profile_compilation_info_作為參數(shù)傳給了CompilerDriver,在之后的編譯過程中將用來判斷是否編譯某個方法和機器碼重排。
CompilerDriver::Compile方法開始編譯dex字節(jié)碼,代碼如下:
void CompilerDriver::Compile(jobject class_loader,
const std::vector<const DexFile*>& dex_files,
TimingLogger* timings) {
for (const DexFile* dex_file : dex_files) {
CompileDexFile(this,class_loader,*dex_file,dex_files,
"Compile Dex File Quick",CompileMethodQuick);
}
}
static void CompileMethodQuick(...) {
auto quick_fn = [profile_index](...) {
CompiledMethod* compiled_method = nullptr;
if ((access_flags & kAccNative) != 0) {
//jni方法編譯...
} else if ((access_flags & kAccAbstract) != 0) {
// Abstract methods don't have code.
} else if (annotations::MethodIsNeverCompile(dex_file,
dex_file.GetClassDef(class_def_idx),
method_idx)) {
// Method is annotated with @NeverCompile and should not be compiled.
} else {
const CompilerOptions& compiler_options = driver->GetCompilerOptions();
const VerificationResults* results = driver->GetVerificationResults();
MethodReference method_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(...);
}
}
return compiled_method;
};
CompileMethodHarness(self,driver,code_item,access_flags,
invoke_type,class_def_idx,class_loader,
dex_file,dex_cache,quick_fn);
}
在CompileMethodQuick方法中可以看到針對不同的方法(jni方法、虛方法、構造函數(shù)等)有不同的處理方式,常規(guī)方法會通過ShouldCompileBasedOnProfile來判斷某個method是否需要被編譯。
具體判斷條件如下:
// Checks whether profile guided compilation is enabled and if the method should be compiled
// according to the profile file.
static bool ShouldCompileBasedOnProfile(const CompilerOptions& compiler_options,
ProfileCompilationInfo::ProfileIndexType profile_index,
MethodReference method_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())) {
return true;
}
// Profile-based compilation without profile for this dex file. Do not compile the method.
return false;
} else {
const ProfileCompilationInfo* 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);
}
return result;
}
}
可以看到是依據(jù)profile_compilation_info_是否命中hotmethod來判斷。我們把編譯日志打開,可以看到具體哪些方法被編譯,哪些方法被跳過,如下圖所示,這與我們配置的profile是一致的。
機器碼生成的實現(xiàn)在CodeGenerator類中,代碼如下,具體細節(jié)將不再展開。
//art/compiler/optimizing/code_generator.cc
void CodeGenerator::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 (HInstructionIterator it(block->GetInstructions()); !it.Done(); it.Advance()) {
HInstruction* current = it.Current();
DisassemblyScope disassembly_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_也會影響機器碼重排,我們知道系統(tǒng)在通過IO加載文件的時候,一般都是按頁維度來加載的(一般等于4KB),熱點代碼重排在一起,可以減少IO讀取的次數(shù),從而提升性能。
odex文件的機器碼布局部分由OatWriter
類實現(xiàn),聲明代碼如下:
class OatWriter {
public:
OatWriter(const CompilerOptions& compiler_options,
const VerificationResults* verification_results,
TimingLogger* timings,
ProfileCompilationInfo* info,
CompactDexLevel compact_dex_level);
...
// Profile info used to generate new layout of files.
ProfileCompilationInfo* profile_compilation_info_;
// Compact dex level that is generated.
CompactDexLevel compact_dex_level_;
using OrderedMethodList = 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.
class OatWriter::LayoutCodeMethodVisitor final : public OatDexMethodVisitor {
public:
LayoutCodeMethodVisitor(OatWriter* writer, size_t offset)
: OatDexMethodVisitor(writer, offset),
profile_index_(ProfileCompilationInfo::MaxProfileIndex()),
profile_index_dex_file_(nullptr) {
}
bool StartClass(const DexFile* 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;
}
return OatDexMethodVisitor::StartClass(dex_file, class_def_index);
}
bool VisitMethod(size_t class_def_method_index, const ClassAccessor::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();
MethodReference method_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)
}
}
OrderedMethodData method_data = {hotness_bits,oat_class,compiled_method,method_ref,...};
ordered_methods_.push_back(method_data);
}
return true;
}
在LayoutCodeMethodVisitor類中,根據(jù)profile_compilation_info_指定的熱點方法的FLAG,判斷是否打開hotness_bits標志位。熱點方法會一起被重排在odex文件靠前的位置。
小結一下,在系統(tǒng)安裝app階段,會讀取apk中baselineprofile文件,經(jīng)過porfman根據(jù)當前系統(tǒng)版本做一定轉換并序列化到本地的reference_profile路徑下,再通過dexoat編譯熱點方法為本地機器碼并通過代碼重排提升性能。
廠商合作
Baseline Profile安裝時優(yōu)化需要Google Play支持,但國內(nèi)手機由于沒有Google Play,無法在安裝期做實現(xiàn)優(yōu)化效果。為此,我們協(xié)同抖音與小米、華為等主流廠商建立了合作,共同推進Baseline Profile安裝時優(yōu)化在國內(nèi)環(huán)境的落地。具體的合作方式是:
- 我們通過編譯期改造,提供帶Baseline Profile的APK給到廠商驗證聯(lián)調(diào)。
- 廠商具體的優(yōu)化策略會綜合考量安裝時長、dex2oat消耗資源情況而定,比如先用默認策略安裝apk,再后臺異步執(zhí)行Baseline Profile編譯。
- 最后通過Google提供的初步顯示所用時間 (TTID) 來驗證優(yōu)化效果(TTID指標用于測量應用生成第一幀所用的時間,包括進程初始化、activity 創(chuàng)建以及顯示第一幀。)
參考鏈接
https://developer.android.com/topic/performance/vitals/launch-time?hl=zh-cn
在與廠商聯(lián)調(diào)的過程中,我們解決了各種問題,其中包括有一個資源壓縮方式錯誤。具體錯誤信息如下:
java.io.FileNotFoundException:
This file can not be opened as a file descriptor; it is probably compressed
原來安卓系統(tǒng)要求apk內(nèi)的baseline.prof二進制是不壓縮格式的。我們可以用unzip -v來檢驗文件是否未被壓縮,Defl標志表示壓縮,Stored標志表示未壓縮。
我們可以在打包流程中指定其為STORED格式,即不壓縮。
private void writeNoCompress(@NonNull JarEntry entry, @NonNull InputStream from) throws IOException {
byte[] bytes = new byte[from.available()];
from.read(bytes);
entry.setMethod(JarEntry.STORED);
entry.setSize(bytes.length);
CRC32 crc32 = new CRC32();
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。經(jīng)測試,7萬+熱點方法文件,生成baseline.prof二進制文件62KB,壓縮率只有0.1%;如果通過通配符配置,壓縮率在5%左右。
一般應用商店下載安裝包時在網(wǎng)絡傳輸過程中做了(壓縮)https://zh.wikipedia.org/wiki/HTTP%E5%8E%8B%E7%BC%A9處理,這種情況不壓縮處理基本不影響包大小,同時不壓縮處理也能避免解壓縮帶來的耗時。
優(yōu)化效果
在自測中,我們可以通過下面的方式通過install-multiple
命令安裝APK。
# Unzip the Release APK first
unzip release.apk
# Create a ZIP archive
cp assets/dexopt/baseline.prof primary.prof
cp assets/dexopt/baseline.profm primary.profm
# Create an archive
zip -r release.dm primary.prof primary.profm
# Install APK + Profile together
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耗時比較 | 未優(yōu)化 | 已優(yōu)化 | 優(yōu)化率 |
榮耀Android11 | 950ms | 884ms | 6.9% |
小米Android13 | 821ms | 720ms | 12.3% |
可以看到,在開啟Baseline Profile優(yōu)化之后,首裝冷啟動(TTID)耗時減少約10%左右,為新用戶的啟動速度體驗帶來了極大的提升。
參考文章
- Android 端內(nèi)數(shù)據(jù)狀態(tài)同步方案VM-Mapping
- 開源 | Scene:Android 開源頁面導航和組合框架
團隊介紹
我們是字節(jié)跳動西瓜視頻客戶端團隊,專注于西瓜視頻 App 的開發(fā)和基礎技術建設,在客戶端架構、性能、穩(wěn)定性、編譯構建、研發(fā)工具等方向都有投入。如果你也想一起攻克技術難題,迎接更大的技術挑戰(zhàn),歡迎點擊閱讀原文,或者投遞簡歷到xiaolin.gan@bytedance.com。
最 Nice 的工作氛圍和成長機會,福利與機遇多多,在北上杭三地均有職位,歡迎加入西瓜視頻客戶端團隊 !