原文:Faster command line tools with Kotlin
作者:Renato Athaydes
翻译:Diwei
译者注:本文作者看完《使用Haskell创建更快的命令行工具》一文后,受此灵感,写了本文,在本文详细介绍了如何使用Kotlin去创建速度更快的命令行工具。以下为译文。
本文的创作灵感来自于使用Haskell创建更快的命令行工具,而Haskell的灵感是来自于Go语言,Go语言又来自于Nim语言,Nim又来自于D语言。
本文我将介绍一下如何使用Kotlin(是指在JVM上运行的情况)来创建速度几乎与最快的本机语言运行类似的命令行工具。
这是本文的主要目标,但我也会介绍如何在JVM基础的工具以及某些直觉的帮助下将程序优化到很高的程度这种基础之上的内容。
先回顾一下最初的D博客文章:
最初的问题描述:
这是一种常见的编程任务:使用分隔符分隔的字段(逗号、制表符等)来获取数据文件,并运行涉及几个字段的数学计算。这些程序通常是一次性使用的脚本,其它时候它们的使用寿命更长。当程序在大文件中使用超过几次的时候,速度当然就会需要关注了。
我们将探索的练习开始于字段中有键的文件,另一个字段中的整数值。字段由TAB分隔了,并且在一行中可能有任意数量的字段。键和值的文件名和字段号作为命令行参数传递。
凭我对Kotlin之前的基准了解,我知道Kotlin是一门非常快的语言。至少它和Java是一样的,这是一件很棒的事情!
注意:当我谈论Kotlin的表现时,我是指Kotlin运行在Oracle的HotSpot JVM上面。尤其是我使用过了了Java version 8u141、Java HotSpot(TM)64位服务器VM(构建25.141-b15,混合模式)。
由Java 8(Kotlin-stdlib-jre8)编译的Kotlin版本1.1.2。
然而,对于类似于本实验中的那些小的脚本在JVM上的表现可能不太好,就是由于JVM缓慢的启动时间。
不管这些了,让我们直接切入正题,看看Kotlin和JVM的表现吧!
V1 - 初始版本
第一个版本的代码与最初的D语言的代码几乎完全相同(允许有语法差异):

为了简便起见,省略了解析参数和处理错误的处理步骤,但是你可以在这里看到。
想要运行这个程序,我们需要输入:
java -cp "build/libs/*" Max_column_sum_by_keyKt ngrams.tsv 1 2
这是结果:
max_key: 2006, sum: 22569013
Total time: 3.37 sec
*注意:本文中所有的报告都是基于Kotlin代码生成的,跟Go语言和Haskell语言的文章中所做的一样。
由于使用了shell的time命令来度量时间,因此所有报告中的时间最多的时候可能会增加0.1秒左右。无论如何,由于获得的时间是来自于不同的语言以及不同的硬件设备,因此这种比较只能被看作是一种理解速度是否是同一个量级的方法。*
鉴于D语言的第一个版本运行时间在3.2秒以内(Go语言在3.49秒内),Kotlin看起来表现还不错!
但是,我们绝对有改进的空间!
让我们来看看我们的程序在jvisualvm(使用Startup Profiler Plugin)上目前为止的表现吧:

首先,Kotlin似乎在引入大量的运行时null检查!我们可能不需要,所以得了解一下它是在哪里被调用的!
在运行时禁用null检查是可能的,但我不会这样做,因为这种做法是非官方的。
使用可视化的VM采样器来检查调用堆栈,原因很明显:

V2 - Java String::split
Kotlin的扩展功能CharSequence::split
,我们可以用它来将每一行的数据进行分隔,但这么做似乎不妥。尽管这个函数比Java的强大得多,但我们所需要的只是更简单的Java版本,所以让我们替换这一行:

改成这样:

因为Java的split
是一个String
而不是一个char
,所以也必须对delim
的类型进行修改。请参阅这里的完整代码。
结果:
max_key: 2006, sum: 22569013
Total time: 2.73 sec
好了!跟第一个版本先比,只调整了一行代码,但是时间却减少了0.6秒!
这极大地改变了应用程序的分析特征:

现在的瓶颈似乎就是卡在了对文件每一行的迭代上了。
为了解决这个问题,除了像Go guy did这样的操作,直接对文件字节进行迭代,我们可以先尝试做一些更简单的调整看看是否会有效。
V3 - 将整数转换成IntBox
让我们看看是否可以通过实现一个int
包装器来避免任何Integer
boxing,从而避免使用结果来避免大量的箱/取消拳击的值。
下面是基于IntBox数据类引入的简单修改的新代码:

由于box允许代码始终执行原始int的操作(新值没有放入映射中,当前框的值只是更改了),因此理论上可以运行得更快。
结果:
max_key: 2006, sum: 22569013
Total time: 2.52 sec
嗯. .不是很大的差别,只是几分之几秒(但更快)。运行几次,时间不会有很大的变化。
我们现在可以做的一件事就是把java包括在内。分析器配置中的类(默认情况下,它们被排除在外),并确切地知道时间在哪里:

不幸的是,现在看起来我们的Java方法占用了大部分时间:复制数组范围(由split
方法调用),String
构造函数本身(有很多字符串被创建!),String::indexOf
(也来自split
)……
V4 - 更快的Map
我曾经想过要使用更快的Map
实现,但是分析器的信息表明这么调整的作用并没有我们预想中的那么大。
但是我很固执,仍然决定尝试一下。首先,通过显式地实例化java.util.HashMap
,而不是使用Kotlin的mutableMapOf
函数(它提供了一个LinkedHashMap
)。
然后,通过使用一个名为fastutil的重量级库,它包含了许多用于Java的原始集合的快速实现(当然,我们可以从Kotlin免费使用)。我决定尝试Object2IntOpenHashMap。它甚至有一个带有签名的适当方法
我认为这至少能让我们在速度上有一个适度的提升(注意这个方法让我摆脱了IntBox
)。
但是,和往常猜测的结果一样,我错了!
下面是使用HashMap
的结果:
max_key: 2006, sum: 22569013
Total time: 2.39 sec
使用Object2IntOpenHashMap
:
max_key: 2006, sum: 22569013
Total time: 2.58 sec
事实证明,fastutil在这个问题上并没有太快(库似乎优化了大型集合,而不仅仅是速度)……但至少我们要使用HashMap
的小改进,这让另一个100的总时间从以前的结果受人尊敬的女士为非2.39秒,基于vm语言(唯一一个到目前为止我读过的文章除了提到的Python D条……值得一提的是,Kotlin本机正在开发中,所以Kotlin可能并不总是依赖JVM来获得最佳性能)。
到目前为止,代码仍然是非常干净的。
但我们仍然落后于D和尼姆,而且还没有比Haskell好得多。
V5 - 处理字节,而不是字符串
为了变得更快,我们似乎需要咬紧牙关,去掉所有byte-to-String
+ String::split
的操作。毕竟它们都不是必须的,因为大多数工作都可以在字节级别上完成,如Go文章中所示的那样。
但是代码的可读性和可维护性会变差吗?!
自己判断:


结果:
max_key: 2006, sum: 22569013
Total time: 1.39 sec
很好的加速!
V6 - ByteArray直接转换成Int #
但是请注意,在将每个值转换成字符串之前,我们仍然会把它先转换成int,这是一种徒劳的工作,而且不用费多大力气就可以避免。
这段代码与上面最后一段代码相比并没有太大的变化,所以如果你感兴趣的话,可以点击这里查看差异。
下面是ByteArray-to-Int函数:

这样做的结果是:
max_key: 2006, sum: 22569013
Total time: 1.18 sec
这与D语言最快的版本已经非常接近了!
但是对于Kotlin来说还可以继续优化!
V7 - 并行
考虑到目前我们所能做的就是进行简单的并行,所以没有理由不尝试这样做。
我们需要的是一个Java的RandomAccessFile
,它可以同时读取文件的不同部分,还需要一些设置代码来将文件分解成多个分区。
这是设置代码:

在前面的例子中,run
函数中的大多数代码都被移动到一个名为maxOfPartition的新函数中,该函数只扫描文件的一个分区,并只返回只用于它的分区的sumByKey
Map。在为每个分区并行计算Map之后,我们将它们合并在一起以得到最终结果。
下面是两个单独的文件分区时的结果:
max_key: 2006, sum: 22569013
Total time: 0.71 sec
尽管我在不同的运行之间得到了一些差异,但是上面的结果似乎与这段代码的平均水平差不多(运行速度较慢,大约0.9秒,但是最快的运行速度是0.67秒,大多数运行都接近于前者)。
所以,尽管我们不得不使用一些非常先进的东西与简单的v1相比,我们能够将速度从3秒提高到1秒以下。记住,我们正在处理一个192.4 MB的文件。
因此,我希望这表明,编写Kotlin的命令行应用程序是可能的。是的,你可以得到类似于d和d的速度。