衡量Kotlin在Uber的绩效

衡量Kotlin在Uber的绩效

本文是与Kotlin团队在JetBrains

在Uber,我们努力在所有应用中保持现代技术栈。Android领域的自然发展是开始采用芬兰湾的科特林它是一种现代的多平台编程语言,也是一种越来越受欢迎的Android开发替代方案,可以与Java完全互操作。

然而,我们的Android系统中有20多个Android应用程序和2000多个模块,Uber的移动工程团队必须仔细评估采用一种新语言这样重要的东西的影响。此评估的许多重要方面包括开发人员生产力、互操作性、运行和构建性能开销、开发人员兴趣和静态代码分析。除此之外,我们还必须确保这一决定不会影响我们Android应用的Uber用户体验。

为了促进这一采用的成功,我们与JetBrains合作,启动了一项计划,在不同的项目结构中大规模地度量Kotlin构建性能为我们决定Android开发的最佳实践提供了依据。

设计注意事项

目标很简单:大规模测量Kotlin构建性能,并理解不同项目结构的权衡。为此,我们为模型结构建立了以下条件:

  • 代码在功能上应该是等价的。这并不一定意味着Kotlin或Java源代码在实现上是相同的,只是它们反映了我们可能在那种语言中如何编写它的函数对等性(例如,Gson TypeAdapter vs. Moshi JsonAdapter)。
  • 代码应该是不平凡的。简单的例子是不够的,因为它们往往不能反映现实世界的情况。为了最准确地执行我们的测试,我们需要利用在生产环境中使用的重要代码。
  • 应该有很多大型的、不同的模块。我们不仅希望通过项目的数量来度量,还希望了解单个Kotlin项目是如何随规模而扩展的。

我们在执行这样的测量时处于一个独特的位置,因为我们为Android生成网络API模型和服务Apache节俭规范。在网络上,这些文件使用基于Retrofit/OkHttp/Gson的网络堆栈作为JSON发送。结构/异常/联合作为标准值类型(pojo)生成。我们为每个. Thrift文件生成一个项目,项目可以依赖于与Thrift“include”语句匹配的其他生成的项目。

这个项目结构在我们可以比较的354个不同项目中产生了140万行代码(LoC)。此外,由于生成了代码,我们可以控制这些项目的形态;例如,我们可以只使用Java代码生成它们,或者只使用Kotlin代码(两者的混合),并启用或禁用注释处理器,以及其他组合。

基于可配置性,我们提出了13个不同场景的矩阵,以细粒度地理解不同的项目结构和工具权衡:

表1:我们的构建性能矩阵包括13个不同的设计场景,导致数百个不同的构建。

我们将为13种配置中的每一种生成354个项目的过程命名为实验。我们总共成功地进行了129次实验

额外的设计注意事项和注意事项

以下是我们在开始这个项目之前考虑到的一些额外的设计考虑和知识:

  • Uber已经使用Buck作为我们的Android/Java构建系统,所以我们没有使用像Kotlin Gradle守护进程和增量Kapt这样的工具进行测试。
  • 在这个基准测试期间,构建是干净的,缓存是关闭的。Buck会缓存已经计算出的规则的结果,以加速未来的构建,在执行基准测试以减少运行之间的可变性时,肯定不希望这样做。
  • Buck的多线程构建被关闭。由于我们不能推断在整个构建执行过程中每个线程所执行的工作是确定的,所以我们不希望多线程模式干扰编译器线程的时间。
  • 我们想要度量纯粹的kotlinc/javac性能,因此没有使用Kotlin的性能避免编译器功能.编译器避免/缓存机制在不同的构建系统之间可能有很大的不同,所以我们决定在这个项目中不使用它。
  • 我们决定在我们的CI机器上执行我们的实验,因为这些实验运行得非常慢,而且我们的CI盒子比个人机器强大得多。
  • 这些都是纯JVM项目。Android项目可能有其他考虑因素,如资源,R类,Android .jar和Android Gradle Plugin。
  • 对Kotlin的Buck支持是由开源社区添加的,目前还没有得到积极的维护。这可能会对性能产生影响,因为Buck的实现可能不会像第一方工具那样经过大量优化。
  • 涉及项目大小比较的分析完全是在源代码级别进行的,没有考虑到生成的字节码。
  • 我们的构建性能数据与编译时间有关,而不是与构建时间有关。构建时间与正在使用的构建系统紧密耦合,例如,Gradle增量构建或Buck并行构建。我们希望我们的分析与构建系统无关,并尽可能将重点放在kotlinc与javac之间。

基于这些考虑,我们创建了一个项目生成工作流,它使我们能够开发数百个模型,用来比较新的基于kotlin的应用程序的构建性能。

项目代工作流

我们的标准模型生成管道是围绕项目生成器的一个简单命令行接口。它从Thrift规范的目录中读取,推断项目依赖关系,然后生成反映这些规范的扁平项目集。这些项目又包含一个Buck文件,该文件带有一个自定义的genrule,该规则调用代码生成器为项目生成适当的源文件。在我们的构建性能实验中,我们分别运行所有的代码生成部分,所以唯一被测量的部分是编译步骤。

图1。Uber的从Thrift文件物化项目的项目生成工作流利用了Buck构建工具。

为了简化这个设置,我们用前面提到的矩阵创建了一个' BuildPerfType ' enum,并在项目生成CLI中添加了一个' -build-perf '选项。然后,分析脚本所要做的就是运行一个命令,例如:

G根据栈对Buck的使用,我们利用OkBuck来包装Buck使用。BuildPerfType枚举成员包含为该规范生成项目所需的所有信息,包括kotlinc的潜在自定义参数、依赖项(包括Kotlin stdlib和Kapt等)以及代码生成的细粒度参数和语言控制。

在代码生成级别,我们实现了对使用生成Java和Kotlin代码的支持JavaPoet而且KotlinPoet.我们已经在代码生成中实现了一个灵活的插件系统,以支持自定义后处理,所以添加必要的控件来促进这些新变体是很容易的。

为了支持混合源集的生成,我们添加了精确指定每种语言应该生成哪些Thrift元素的支持。为了支持无kart生成,我们实现了可选的直接生成类的支持,否则将在注释处理期间生成这些类。即对生成的支持匕首工厂(例子)和Moshi Kotlin模型(基于此把请求).

下面的图2显示了基于它们的大小(通过文件数量度量)生成的项目的分布。平均而言,每个项目有27个文件(即,13个构建性能类型中所有354个项目的平均文件总数)。每个文件的平均行数是200(即,平均文件数除以平均行数,这是Java和Kotlin的平均代码行数、注释行数和空行数的和)。

图2。尽管不同,我们实验的最大项目文件大小约为500个文件。对于这个特性,我们可能会改进数据集,以适应更大的项目。然而,可用的项目已经足够使分析结果不受任何固定的可能变量(在本例中为文件总数)的影响。

实验执行

为了运行我们的实验,我们采取了以下步骤:

  • 仪器的过程.这主要意味着进入我们的构建系统内部,让它发布我们分析所需的指标。
  • 整合数据。在将数据传送到数据库之前,我们必须就数据的格式达成一致。这些数据的索引方式将直接影响我们在Kibana(我们的前端系统)中构建可视化的能力。
  • 把它发送到我们的内部数据库.在Uber,针对这类指标有多个数据库,每个数据库都针对特定场景进行了优化。我们选择ElasticSearch和Kibana进行这个实验,因为我们想要的可视化可以更好地构建在其中。
  • 始终如一地再说一遍。我们需要足够大的数据量,以消除任何可能损害数据分析的异常值。

一个Python脚本编排了实验的执行;这类实验的语言选择对实验性能没有影响,是基于团队熟悉度来选择的。构建性能数据是由我们的构建系统以Chrome可跟踪文件的形式提供的,尽管这是构建系统的一个标准功能,但我们仍然必须修改内部的Buck fork,以便将我们需要的数据与收集数据的项目上下文相关联。

图3。我们实验的基准工作流CI由CI作业本身、一个模型生成管道、收集和整合构建数据以及将实验结果文件提交到Git存储库组成。

对于与项目形态相关的数据,例如文件的数量、空白行数、注释或代码的行数以及生成的类和接口的数量,我们使用计算代码行数(CLoC) CLI和正则表达式,分析生成的项目源文件,而不是其编译的字节码。收集完所有数据后,将其组装成单个JSON文件并提交到单独的Git存储库中。整个流程每两小时在CI环境中运行一次,持续了大约两周。之后,脚本的另一部分负责同步结果存储库,并将数据发送到我们的内部数据库,在那里可以对数据进行分析。

图4。为了在本地工作流中将基准测试结果发布到云中,我们克隆存储所有结果的存储库,将它们转换为先前定义的模式,然后将最终数据发布到ElasticSearch。这样做主要是为了克服CI环境中的约束,并使共享结果更容易。

在开发环境中运行实验(在笔记本电脑上)不是一个选择。造成这种情况的主要原因是,当地的开发环境不是确定性的;换句话说,在每次运行时,由于后台运行的任务,机器的状态会发生改变。此外,实验执行速度太慢(在CI硬件上平均需要两个小时才能完成实验)。脚本的手动触发也会消耗工程师大量的时间,并降低工作效率,因为它阻止他们使用笔记本电脑执行其他任务。

硬件规格

结果

我们分析了数据使用ElasticSearch而且Kibana.分析基于构建性能矩阵(表1)在桶中聚合数据。在该表中,第一行显示的编译时间表示所有实验运行的所有354个项目的平均编译时间。下面描述了最有趣的见解:

图5:对于这个实验,我们测量了每种配置类型的总编译时间(kontlinc + javac)(见表1)。y轴表示每种组合的编译时间,每个柱状图表示不同的配置。

Javac和kotlinc在整个实验中表现一致

在129个实验中,javac和kotlinc报告的时间一致。这说明实验环境控制得很好。

图6a和6b: javac和kotlinc时间的一致性,以秒为单位,对于每条矩阵行,证明我们的实验环境得到了很好的控制。

容易出错目前在纯Java的基础上增加了大约70%的开销(基线)

Error Prone, Java静态分析工具自带一套标准的跳棋。它的主要特性之一是允许用户通过添加自定义检查器来扩展其分析能力。我们认为,过度使用该特性、类加载器的不共享以及编写糟糕的检查程序可能是造成这种开销的原因。

Kotlin隐式/显式类型对总体编译开销的影响很小

声明还是不声明类型可能是在编写代码时出现的问题。这可能涉及推断返回类型、lambda、成员引用和泛型。至少从构建性能的角度来看,在代码中使用隐式类型还是显式类型并不重要。在iOS开发人员发现Swift编译器中存在显著的推理惩罚后,我们对测量这一点特别感兴趣。

Kotlin的新类型推断系统在总编译时间上增加了约8%的开销

虽然芬兰湾的科特林的新型推理系统仍处于实验阶段,它增加了一些新的改进。此外,由于它仍处于孵化阶段,因此我们并不惊讶于它比我们测试的其他类型推断系统运行得更慢。

总的来说,Kotlin的源代码行数比Java少40%

Kotlin以能够用更少的代码实现更多的功能而闻名,而我们的实验就是这一常识的证明。“源代码”中的“源”在这里也很重要——Kotlin编译器生成大量合成元素,否则这些元素将需要手工包含在等价的Java源代码中。这包括数据类等表面副本()/ hashCode () = () / toString ()函数和解构组件方法(尽管这个语言特性只对Kotlin使用者有用)。

图7:对于这个实验,我们测量了每个语言每个项目的代码、空白和注释行的平均数量,并确定对于功能等效的代码,Kotlin项目比Java项目小40%左右。

与不带Kapt的纯Kotlin相比,带Kapt利用纯Kotlin的项目要慢得多

与纯Kotlin相比,带Kapt的Kotlin增加了大约95%的开销。我们认为出现这种情况的原因有以下几点:

  1. 项目总是从一个干净的状态编译,这意味着任何类型的编译避免或增量编译都是不可能也不需要的(关于为什么会发生这种情况,下一项将详细说明)。
  2. Buck的观点是不支持增量编译,因为这会导致编译状态难以重新生成(想象一下,重新生成编译失败的步骤中有许多Git补丁与构建命令交织在一起)。考虑到这一点,我们不希望仅通过使用这些功能来改进结果。
  3. Buck对Kapt的实现不是最优的,因为它两次调用“kotlinc”来运行注释处理(一次用于生成存根,一次用于真正的注释处理),另一次用于实际编译,总共三次调用“kotlinc”。一个优化的实现会让所有的Kapt阶段和编译在一个“kotlinc”调用中运行。但是这还没有在编译器上完全实现,所以还不能在Buck上完成。我们不确定这个实现对Kotlin + Kapt构建性能的影响有多大,目前还无法收集优化版本的数据。

即使有这些原因,与不带Apt的纯Java相比,纯Java加上Apt的速度要快得多(只有大约5%的开销),这是很奇怪的。

图8:我们测量了纯Kotlin与Kapt(粉色)和纯Kotlin(蓝色)设置之间的项目性能,在左边,纯Java与Apt(棕色)和纯Java(绿松石色),在右边。

我们开发的一个理论是,为什么apt支持的构建执行得这么好,因为该软件非常老,并且一直处于不断的开发中,这允许随着时间的推移进行性能优化,这在Kapt中还没有发生,因为它是一个更年轻的解决方案。另外,注释处理是为Java设计的,可以在javac编译器的进程内运行,因为两者共享相同的AST。这在Kotlin中很难优化。除此之外,Apt的使用不像Kotlin那么频繁,因为我们的实验重点是Kotlin。

下面的图9描述了使用Kapt运行的项目如何呈现比使用Java构建的项目更少的文件,这表明Kapt必须生成比Apt多得多的文件。

总编译时间基本上与项目大小(文件数量)成线性增长。

尽管我们期望(或至少希望)编译时间和文件数量之间存在线性相关关系,但这是一个很好的指标,表明我们不需要过多地担心围绕模块大小创建强制执行。虽然保持项目规模小是很重要的,这样就不会有单个项目因为花费太长时间来完成而阻塞线程,系统可以继续保持性能,但当涉及到导致单个构建时间的指数增长时,这不是一个值得关注的问题。

图9:在我们的实验中,我们测量了基于项目规模的总编译时间(javac + kotlinc)增长。在这个图中,y轴表示总的编译时间,平均而言,x轴表示基于物化文件总数的项目大小(换句话说,这里不计算由Kapt或Apt生成的文件)。

结论

在执行这类分析时,很难涵盖可以使用主题语言的所有不同排列。正如本文中所描述的,我们试图利用我们现有的基础设施来运行这个实验,并测试尽可能多的场景(参见表1)。然而,我们的13个选项仍然只是现有选项的一个子集。我们希望这个分析可以成为其他利用Kotlin的组织的北极星。

我们的结果表明,在同一个模块中混合使用Kotlin和Java源代码应该谨慎,主要是在具有高提交吞吐量的大型存储库的范围内,这样在CI构建中损失的每一分钟都会产生不同的影响。导入Kotlin依赖项的Java项目(反之亦然)不属于这个类别。

在Uber,我们广泛依赖于“易出错”(Error Prone)来执行静态分析。我们有超过60个自定义检查器,每天可以被触发数百次,以防止许多错误。

图10:2019年3月和4月触发的易出错检查器突出了我们在一些实验中可能产生的开销。

自定义检查器数量的增加,以及我们不为不同的javac调用共享它们的类加载器这一事实,可能要为这个基准测试中观察到的70%的开销负责。删除不经常触发的检查器并开始共享它们的类加载器是我们可以立即采取的可操作项目,以加快这个场景。

经验教训

评估一种语言的采用是一件非常复杂的事情,它涉及到许多变量,这些变量可能远远超出了编译时间的开销,例如,对该语言的整体社区支持、可读性和本机支持的特性,以及其他特性。

对于Kotlin是否适合您的项目或团队这个问题,显然没有简单的答案。最后,决定是否采用一种编程语言—或它们的组合—需要您评估它们之间的权衡。在Uber最近的一次内部调查中,我们询问了近100名移动工程师,他们是否愿意为了能够使用Kotlin而接受较慢的构建时间。结果呢?95%的人表示,如果他们可以用Kotlin编写代码,他们愿意接受较慢的构建。

额外的注意事项

为了改进Kotlin构建性能分析,可以做很多事情。本节介绍了我们想要执行的一些度量和分析,但由于各种原因,未能执行;我们鼓励其他人自己探索这些可能性。

  • IntelliJ项目索引时间.捕获这些数据需要比本文中介绍的更多的工作,值得专门撰写一篇文章。
  • 较大的项目.由于采样空间的性质,这种分析仅限于较小的项目(介于1到500个文件之间,平均文件大小为27)。在项目规模方面有一个更多样化的代表是更好分析的必要条件。
  • 其他的构建工具。通过试图将构建系统从分析中分离出来,我们无法评估为特定构建系统提供或设计的某些特性的影响,例如Gradle增量编译。理解前面提到的场景如何使用这些特性,可能会揭示可以提高编译速度的设计,并且希望能够讨论如何将这些工具引入更广泛的构建系统。
  • 其他芬兰湾的科特林的功能。Kotlin提供的广泛功能集让我们想要进一步改进这一分析,以便解释以下情况:
    • 智能强制转换:在生成的模型项目中,我们不需要智能强制转换,因此它不在我们的分析中。但是,这可以作为另一个衡量编译器性能的潜在领域。
    • Buck-specific:
      • ABI jar支持:这是在Kotlin 1.2.70中添加的,它允许计算给定库的ABI,以快速确定是否需要重新编译消费者。这是像Buck和Bazel这样的构建系统如何工作的重要部分。
      • 研究Buck上增量Kapt的可行性。目前这只在Gradle中实现,但它可能是提高Buck项目编译速度以及使用Kapt的一个可能领域。
  • 了解第三方依赖jar大小的影响。重度依赖可能在测量场景中扮演更大的角色。将它们从分析中分离出来可能会带来更大的洞见。
  • Java对混合源项目的影响.尽管我们有一个指标告诉我们项目中出现的Java文件的百分比,但它与项目的大小紧密耦合;换句话说,随着Java的百分比的变化,项目的大小也会发生变化。因此,这条曲线似乎更倾向于项目的规模,而不是项目中Java的数量。由于该分析不是结论性的,所以我们选择在本文中不进行分析。
  • 一个连续的管道来比较随时间推移的工具改进。软件开发的自然发展给观察到的编译时间带来了一些变化。拥有一个生产数据的可靠管道是理解环境中新特性影响的最佳方法。拥有开源的项目数据集只会增加分析的范围,并揭示更好的见解。

通过分享我们的成果和经验教训,我们希望其他人可以用它来指导他们自己的决定。

为了更好地概述生成的代码,我们创建了存储库每个变体的示例代码和底层技术堆栈的细节。

订阅我们的通讯以跟上优步工程的最新创新。

评论

没有可显示的帖子