Post

KMP Debug构建效率提升

介绍KMP(Kotlin Multiplatform)项目中优化鸿蒙(HarmonyOS)Debug包构建效率的实用方法,包括排查Gradle任务,开启增量缓存机制,常见问题及解决方案。通过静态缓存和依赖优化,显著提升Debug构建速度,降低开发调试时间。

KMP Debug构建效率提升

优化KMP打鸿蒙debug包耗时,优化目标约耗时1分钟

首先Kotlin官方文档覆盖的场景比较全,建议先按照这个排查。其中 kotlin.incremental.native 配置kuikly版本在鸿蒙上截止KBA-014版本暂不支持,下文会介绍如何开启

gradle 任务排查

这段在官方文档基础上补充一点针对gradle构建任务进行排查的建议

通过在 ./gradlew 命令最后添加 –profile 或 –scan 可以收集各gradle task的执行耗时,使用scan获取的信息更详细,但需要将数据上传到develocity服务器。profile选项是纯本地的,也能展示基本的任务执行和耗时情况。

下图是 ./gradlew :app:linkPlCheckDebugSharedOhosArm64 --profile 结果

alt text

其中Result列

  • 空白:task被执行了
  • UP-TO-DATE:task的输入跟上次执行比没变,直接使用上次的结果
  • SKIPPED:有注册这个task但是不满足执行条件,没有执行
  • FROM-CACHE:通过 org.gradle.caching=true 开启gradle build cache功能后,gradle会缓存每个task多次执行的结果。这个task的输入和上次执行时不一样所以不是UP-TO-DATE的,但是输入和之前某次执行一样,gradle直接从build cache中找到了这个输入对应的输出,task也没有被执行

针对gradle任务的优化目标是修改一行代码后

  1. 尽可能少触发gradle任务

    Kotlin/Native 构建至少触发两个gradle任务 compileKotlin 和 link。按照KGP的规则,修改一个gradle子项目中的kotlin代码后所有依赖这个项目的其他子项目都会重新运行 compileKotlin 任务,且依赖会传递。如下图项目结构,修改模块C的代码后C,B和A的 compileKotlin 任务都会重新执行。有除此之外的gradle任务都是业务代码/项目依赖中配置的,可以检查下这些任务是否可以通过gradle cacheable task进行缓存。整改参考gradle文档gradle incremental build文档。 alt text https://excalidraw.com/#json=BX4kEo4nkZLn3-0G6mM_i,0QDBIdOAHzVB-UsBwGf1Uw

  2. 常被触发的gradle任务优化耗时

    这里主要的反模式是在顶层gradle项目中写或者生成大量代码。顶层gradle子项目的 compileKotlin 任务的每次debug build都会执行且这个任务不支持增量,导致debug build耗时长。将代码放到顶层项目中时应慎重,尤其是生成大量代码的场景。可以选择项目中几个常发生修改的位置修改后构建,观察触发的gradle任务,对触发频率高且耗时长的子项目进行优化

    如一些代码原来在上图的A模块中,如果这些代码实际不依赖B模块可以考虑挪到D,避开debug过程中触发cache miss概率高的依赖路径。或者如果模块A包含多个独立功能,可以考虑拆分模块A成几个小模块并分别配置依赖关系。总之让经常构建的项目尽可能小,必须很大的项目尽可能减少依赖少触发构建

OHOS Debug Build 开启增量编译

增量编译开/关时的构建流程如下图

alt text

https://excalidraw.com/#json=8ZS-lGRqJBFBqiRV1CgKW,KHva9hBjSKVuLbHfBl-mVg

增量编译只影响编译器后端的link任务,对 compileKotlin 任务没有任何影响。提速的原理是开启增量编译之前尽管工程中实际变化的源码很少,但KN编译器后端每次都完整的将整个工程对应的LLVM IR编译成so(图中1)。开启增量编译后工程的源码和依赖都被编成独立的 .a 静态库(图中2)最后链接到一起(图中5),构建 .a 的范围只是工程中发生修改和依赖被修改文件的其他文件(图中3 4静态缓存没有重新构建),这一范围只是整个工程很小的一个子集,编译的工作量比全量编译显著变少,而且实现上一个gradle子项目中的不同文件编 .a 有并行化,使得link gradle任务的速度显著提升

开启方式

参考PR

增量编译的静态缓存分两个类型

  • 工程依赖的klib因为只会通过修改版本号方式整个替换,其内容不会发生部分修改,整个klib被打包成一个 .a
  • 工程中的源码文件可以独立修改,每个文件被打包成一个 .a

增量编译功能开启后就会给项目依赖的klib打per-klib缓存,项目源码的per-file缓存需要在项目中添加额外的配置开启

依赖klib缓存

在kotlin仓库konan.properties文件cacheableTargets中添加ohos,表示ohos上支持缓存,默认启用缓存功能,会给工程依赖打per-klib缓存

image.png

per-klib缓存保存在kotlin编译器中,路径 $KONAN_DATA_DIR/kotlin-native-prebuilt-macos-aarch64-[kotlin版本号]/klib/cache/ohos_arm64STATIC-pl,其中KONAN_DATA_DIR如果没有设置默认为 ~/.konan。在上述文件夹中看到工程的依赖则项目则依赖klib缓存开启成功

alt text

工程源码缓存

在应用工程根目录的gradle.properties中添加 kotlin.incremental.native=true 配置,会在打包所有支持增量构建的target时(如上面截图中ios_arm64和ohos_arm64都配置了支持增量构建)给项目中的源码打per-file缓存

image.png

per-file缓存保存在构建命令所在gradle子项目的 build/kotlin-native-ic-cache/debugShared/ohos_arm64-gSTATIC-pl 中,每个文件夹是一个gradle子项目,子项目文件夹中有对应每个文件的文件夹

alt text

进阶配置

  1. kotlin项目 konan.properties optInCacheableTargets 配置项中如果配置了ohos,则ohos target支持增量构建但默认关闭,需要在应用工程中添加 kotlin.native.cacheKind.ohosArm64=static 开启缓存功能
  2. 使用静态缓存时如果遇到问题,可以在应用工程中配置 kotlin.native.cacheKind.ohosArm64=none 关闭缓存功能
  3. 构建工程源码的静态cache时并行度默认为4,可以通过在应用工程中配置 kotlin.native.parallelThreads=0 配置并行度为cpu核数

常见问题

  1. 链接命令失败,ld.lld报命令过长

    所有静态cache是以绝对路径形式传给ld的,ld对输入长度有限制而且命令过长会影响性能。ld接受 @/path/to/command.txt 格式的输入,文件中的每一行都被认为是一个链接命令的输入,如在 command.txt 中写

    1
    2
    3
    
     opt1
     opt2
     opt3
    

    ld.lld @/path/to/command.txt 等价于 ld.lld opt1 opt2 opt3。因此通过将所有 .a 路径写入文件的方式传给ld。需要注意target ios的linker不接受这种格式的输入,实现要控制影响范围

  2. 链接命令耗时不稳定,有时很慢

    链接时需要读取大量小 .a 缓存文件,数量=工程中kt文件数+依赖klib数,这是典型的低效场景。不过MacOS(Unified Buffer Cache)和 Linux(Page Cache)系统层面针对频繁读取小文件场景都有优化,这种文件会直接被保存在内存中,避免大量低效磁盘IO。一般会观察到电脑重启后第一次debug build或电脑内存不足时link gradle任务很慢,执行2~3次debug build后时间基本保持稳定。ld.lld命令的耗时和工程规模有关不过应该在10s以下。这里如果希望进一步优化可以考虑将一个源码gradle子项目的所有 .a 在构建命令完成的空闲时间合并成一个 .a,每次构建时被修改的源码模块以per-file的形式传给ld,其他没有修改的源码模块都传per-klib的 .a,有效减少ld的输入文件规模

  3. 链接命令失败,报 error: duplicate symbol: kfun: xxx,这个符号在两个 .a 中被重复定义

    上游社区已知问题 KT-81760。K/N编译出的函数名中中包含kotlin代码package名,函数名,参数和返回值,出现这一问题应该是在同一个package下重复定义相同的函数,这种写法使用哪个实现在不开启增量缓存时依赖声明依赖的顺序,哪个先被声明哪个生效,容易出错,上游社区也可能在新版本中加强校验。如果在项目工程源码中存在这种情况建议整改,如果引入的库之间有符号名冲突不好修改可以添加 –allow-multiple-definition 链接选项使用链接时第一个遇到的定义,将相关的正确性校验延后到不开启cache的debug build或release build进行。更多冲突情况下的细节可以查看这个demo

  4. cache文件夹有pl结尾和没有pl结尾两个,有什么区别

    pl代表kotlin的partial linkage功能,这一功能默认开启,开启后当工程依赖的一个klib调用了另一个klib中不存在的接口时,编译不会报错而是在调用点放一个抛出异常,报 No function found for symbol xxx,把问题遗留到运行时。这是一个demo。kotlin标准库是禁用pl功能的,所以这个库的缓存在kotlin编译器的一个不带pl的文件夹里

    pl功能可能导致一些在编译过程中可以检出的问题漏到运行时,可以在项目的build.gradle.kts中添加一个header_cache类型的构建目标,禁用pl,检查接口兼容性问题

    1
    2
    3
    
     sharedLib("check") {
         freeCompilerArgs += listOf("-produce", "header_cache", "-Xpartial-linkage=enable", "-Xpartial-linkage-loglevel=error", "-opt")
     }
    
  5. 开启增量构建后so体积膨胀

    当注释掉K/N编译器中O0和O0 LTO两条优化管线相关的代码跳过llvm ir上的编译优化时,是否启用静态cache产物so体积差异1%左右,不跳过这两个管线体积差异很大,确定是llvmß ir上编译优化质量变差导致的so体积膨胀。开启静态cache后编译成二进制的程序规模变小,函数定义看不到所有的调用点无法进行有效的死代码删除。下面打印的llvm o0管线中包含dce

    1
    2
    3
    4
    5
    
       bin ./clang -O0 -c test.c -mllvm -print-pipeline-passes     
     function(ee-instrument<>),always-inline,function(loop(loop-idiom-vectorize)),coro-cond(coro-early,cgscc(coro-split),coro-cleanup,globaldce),function(annotation-remarks)
    
       bin ./clang -O0 -flto -c test.c -mllvm -print-pipeline-passes
     function(ee-instrument<>),always-inline,function(loop(loop-idiom-vectorize)),coro-cond(coro-early,cgscc(coro-split),coro-cleanup,globaldce),canonicalize-aliases,name-anon-globals,function(annotation-remarks),BitcodeWriterPass
    

    开启增量构建后链接阶段才能看到完整的程序,给ld.lld传入 –gc-section 类似llvm ir上的dce会删掉所有调用不到的二进制section,配合生成二进制时传入 -ffunction-section 和 -fdata-section 让每个函数/全局/静态变量放进一个独立的section可以做到和全局O0+O0 LTO近似的so体积

  6. cache的粒度

    如前所述应用工程中的代码是按照文件进行的缓存,值得一提的是代码的静态缓存依赖关系也是以文件为粒度分析的,当修改一个文件,只有依赖被修改文件的文件会重建cache,而不是被修改文件所在包的所有文件和依赖这个包的所有包的所有文件。这个设计对规模很大的gradle子项目更友好,缺点是产生了非常多的小文件

  7. 有关gradle build cache

    这和本节重点增量构建静态缓存不是一个概念,gradle build cache是当一个gradle任务的cache key和之前运行的一次完全相同时直接用之前执行的结果,跳过本地执行。build cache不只保留一次结果,所以比如代码中一个变量的值修改历史为 true,false,true,第三次虽然较第二次有修改,gradle 任务不是 UP TO DATE,但是和第一次取值相同会cache hit,gradle任务状态为 FROM CACHE。这种情况下直接从gradle build cache之前的执行记录中取出各步骤执行结果,per-file-cache文件夹下的 .a 和修改后的代码不是对应的

  8. 开启cache后不生效

    控制是否要建cache的代码在下图位置,可以debug观察哪个条件没满足

    image.png

  9. 一些更详细的控制选项:org.jetbrains.kotlin.incremental.IncrementalCompilationFeatures

  10. 其他可能的优化点

    • 使用thinlto优化ld命令耗时
    • 对缓存按照依赖关系进行分桶,最好的场景是每次修改只有一个桶miss,其他桶直接使用前一次链接好的大a
    • remote cache
    • 缓存根据对外暴露的接口signature是否变化进行cache miss传播

相关资料

All Rights Reserved.