,而 Swift 则正在谷歌和苹果的共同养育下茁壮成长,有望成长为深度学习领域一门新的主要语言。近日,Tryolabs 的研究工程师 Joaquín Alori 发布了一篇长文,从 Python 的缺点一路谈到了谷歌在 Swift 机器学习方面的大计划,并且文中还给出了相当多一些具体的代码实例。可微分编程真如 Yann LeCun 所言的那样会成为新一代的程序开发范式吗?Swift 又将在其中扮演怎样的角色?也许你能在这篇文章中找到答案。
近日,国外一小哥在 tryolabs 上写了一篇博文,为我们详尽地介绍了 Python 的缺陷与相比之下 Swift 的优势,解释了为什么 Swift 版的 TensorFlow 未来在机器学习领域有非常好的发展前景。其中包含大量代码示例,展示了如何用 Swift 优雅地编写机器学习程序。 两年之前,谷歌的一个小团队开始研究让 Swift 语言成为首个在语言层面上一流地整合了可微分编程能力的主流语言。该项目的研究范围着实与众不同,而且也取得了一些出色的初期研究成果,似乎离公众应用也并不很远了。 尽管如此,该项目却并未在机器学习社区引起多大反响,而且很多实践者还对此浑然不觉。造成这种结果的主要原因之一是语言的选择。机器学习社区的很多人很大程度上并不关心 Swift,谷歌研究它也让人们感到疑惑;因为 Swift 主要用来开发 ios 应用而已,在数据科学生态系统中几乎毫无存在感。 不过,事实却并非如此,只需粗略地看看谷歌这个项目,就能发现这是一个庞大且雄心勃勃的计划,甚至足以将 Swift 确立为机器学习领域的关键成员。此外,即使我们 Tryolabs 也主要使用 Python,但我们还是认为 Swift 是一个绝佳的选择;也因此,我们决定写这篇文章以帮助世人了解谷歌的计划。 但在深入 Swift 以及「可微分编程」的真正含义之前,我们应该先回顾一下当前的状况。 Python,你怎么了?! 到目前为止,Python 都依然是机器学习领域最常被使用的语言,谷歌也有大量用 Python 编写的机器学习软件库和工具。那么,为什么还要用 Swift?Python 有什么问题吗? 直接说吧,Python 太慢了。另外,Python 的并行性表现并不好。 为了应对这些缺点,大多数机器学习项目在运行计算密集型算法时,都会使用用 C/C++/Fortran/CUDA 写的软件库,然后再使用 Python 将不同的底层运算组合到一起。对于大部分项目而言,这种做法其实效果很好;但总体概况而言,这会产生一些问题。我们先看看其中一些问题。 外部二进制文件 为每个计算密集型运算都调用外部二进制文件会限制开发者的工作,让他们只能在算法的表层的一小部分上进行开发。比如,编写自定义的卷积执行方式是无法实现的,除非开发者愿意使用 C 等语言来进行开发。大部分程序员都不会选择这么做,要么是因为他们没有编写低层高性能代码的经验,要么则是因为在 Python 开发环境与某个低层语言环境之间来回切换会变得过于麻烦。 这会造成一种不幸的情况:程序员会尽力尽量少地写复杂代码,并且默认情况更倾向于调用外部软件库的运算。对于机器学习这样动态发展的领域来说,这并不是一个好现象,因为很多东西都还并未确定下来,还非常需要新想法。 对软件库的抽象理解 让 Python 代码调用更低层代码并不如将 Python 函数映射成 C 函数那么简单。不幸的现实是:机器学习软件库的创建者必须为了性能而做出一些开发上的选择,而这又会让事情变得更加复杂。举个例子,在 TensorFlow 图(graph)模式中(这是该软件库中唯一的性能模式),你的 Python 代码在你认为会运行时常常并不运行。在这里,Python 实际上的作用是底层 TensorFlow 图的某种元编程(metaprogramming)语言。 其开发流程为:开发者首先使用 Python 定义一个网络,然后 TensorFlow 后端使用该定义来构建网络并将其编译为一个 blob,而开发者却再也无法访问其内部。编译之后,该网络才终于可以运行,开发者可以开始向其馈送数据以便训练和推理。这种工作方式让调试工作变得非常困难,因为在网络运行时,你没法使用 Python 了解其中究竟发生了什么。你也没法使用 pdb 等方法。即使你想使用古老但好用的 print 调试方法,你也只能使用 tf.print 并在你的网络中构建一个 print 节点,这又必须连接到网络中的另一个节点,而且在 print 得到任何信息之前还必须进行编译。 不过也存在更加直接的解决方案。用 PyTorch 时,你的代码必须像用 Python 一样命令式地运行,唯一不透明的情况是运行在 GPU 上的运算是异步式地执行的。这通常不会有问题,因为 PyTorch 对此很智能,它会等到用户交互操作所依赖的所有异步调用都结束之后才会转让控制权。尽管如此,也还是有一些问题存在,尤其是在基准评测(benchmarking)等任务上。 行业滞后 所有这些可用性问题不仅让写代码更困难,而且还会导致产业界毫无必要地滞后于学术界。一直以来都有论文在研究如何调整神经网络中所用的低层运算,并在这一过程中将准确度提升几个百分点,但是产业界仍然需要很长时间才能实际应用这些进展。 一个原因是即使这些算法上的改变可能本身比较简单,但上面提到的工具问题还是让它们非常难以实现。因此,由于这些改进可能只能将准确度提升 1%,所以企业可能会认为为此进行投入并不值得。对于小型机器学习开发团队而言,这个问题尤为明显,因为他们往往缺乏可负担实现/整合成本的规模经济。 因此,企业往往会直接忽略这些进步,直到这些改进被加入到 PyTorch 或 TensorFlow 等软件库中。这能节省企业的实现和整合成本,但也会导致产业界滞后学术界一两年时间,因为这些软件库的维护者基本不会立即实现每篇新论文提出的新方法。 举个具体的例子,可变形卷积似乎可以提升大多数卷积神经网络(CNN)的性能表现,但论文发布大概 2 年之后才出现第一个开源的实现。不仅如此,将可变形卷积的实现整合进 PyTorch 或 TensorFlow 的过程非常麻烦,而且最后这个算法也并没得到广泛的使用。PyTorch 直到最近才加入对它的支持,至于官方的 TensorFlow 版本,至今仍没有见到。 现在,假设说有 n 篇能将准确度提升 2% 的论文都遇到了这种情况,那么产业界将错失准确度显著提升 (1.02^n)% 的机会,而原因不过是没有合适的工具罢了。如果 n 很大,那就太让人遗憾了。 速度 在某些情况中,同时使用 Python 与快速软件库依然还是会很慢。确实,如果是用 CNN 来执行图像分类,那么使用 Python 与 PyTorch/TensorFlow 会很快。此外,就算在 CUDA 环境中编写整个网络,性能也可能并不会得到太多提升,因为大卷积占据了大部分的推理时间,而大卷积又已经有了经过良好优化的代码实现。但情况并非总是如此。 如果不是完全用低层语言实现的,那么由很多小运算组成的网络往往最容易出现性能问题。举个例子,Fast.AI 的 Jeremy Howard 曾在一篇博客文章中表达了自己对用 Swift 来做深度学习开发的热爱,他表示尽管使用了 PyTorch 那出色的 JIT 编译器,他仍然无法让 RNN 的工作速度比肩完全用 CUDA 实现的版本。 此外,对于延迟程度很重要的情况,Python 也不是一种非常好的语言;而且 Python 也不能很好地应用于与传感器通信等非常底层的任务。为了解决这个问题,一些公司的做法是仅用 Python 和 PyTorch/TensorFlow 开发模型。这样,在实验和训练新模型时,他们就能利用 Python 的易用性优势。而在之后的生产部署时,他们会用 C++ 重写他们的模型。不确定他们是会完全重写,还是会使用 PyTorch 的 tracing 功能或 TensorFlow 的图模式来简单地将其串行化,然后再围绕它使用 C++ 来重写 Python。不管是哪种方式,都需要重写大量 Python 代码。对于小公司而言,这样做往往成本过高。 所有这些问题都是众所周知的。公认的深度学习教父之一 Yann LeCun 就曾说机器学习需要一种新语言。他与 PyTorch 的创建者之一 Soumith Chintala 曾在一组推文中讨论了几种可能的候选语言,其中提到了 Julia、Swift 以及改进 Python。另一方面,Fast.AI 的 Jeremy Howard 似乎已经下定决心站队 Swift。 谷歌接受了挑战 幸运的是,谷歌的 Swift for TensorFlow(S4TF)团队接过了这一难题。不仅如此,他们的整个项目进展还非常透明。他们还发布了一份非常详实的文档(https://github.com/tensorflow/swift/blob/master/docs/WhySwiftForTensorFlow.md),其中详细地介绍了他们做出这一决定的历程,并解释了他们为这一任务考虑过的其它语言并最终选中 Swift 的原因。 在他们考虑过的语言中,最值得关注的包括: Go:在这份文档中,他们表示 Go 过于依赖其接口提供的动态调度,而且如果要实现他们想要的特性,必须对这门语言进行大刀阔斧的修改。这与 Go 语言的保持简单和小表面积的哲学不符。相反,Swift 的协议和扩展都有很高的自由度:你想要调度有多静态,就能有多静态。另外,Swift 也相当复杂,而且还在越来越复杂,所以再让它复杂点以满足谷歌想要的特性并不是什么大问题。 C++ 和 Rust:谷歌的目标用户群是那些大部分工作都使用 Python 的人,他们更感兴趣的是花时间思考模型和数据,而不是思考如何精细地管理内存或所有权(ownership)。Rust 和 C++ 的复杂度都足够,但都很注重底层细节,而这在数据科学和机器学习开发中通常是不合理的。 Julia:如果你在 HackerNews 或 Reddit 上读到过任何有关 S4TF 的帖子,那么最常看到的评论是:「为啥不选 Julia?」在前面提到的那份文档中,谷歌提到 Julia 看起来也很有潜力,但他们并未给出不选 Julia 的靠谱理由。他们提到 Swift 的社区比 Julia 大得多,事实确实如此,然而 Julia 的科研社区和数据科学社区却比 Swift 大得多,而这些社区的人才更可能更多地使用 S4TF。要记住,谷歌团队的 Swift 专业人才更多,毕竟发起 S4TF 项目的正是 Swift 的创建者 Chris Lattner,相信这在谷歌的决定中起到了重大的作用。 一种新语言:作者认为他们在宣言中说得很好:「创建一种语言的工作量多得吓人。」这需要太长的时间,而机器学习又发展得太快。 那么,Swift 的优势在哪里? 简单来说,Swift 让你可几乎完全用 Python 的方式在非常高的层面上进行编程,同时又可以保证非常快的速度。数据科学家可像使用 Python 一样来使用 Swift,同时可用 Swift 内置的已优化机器学习库来进行更加精细的开发,比如管理内存,甚至当常用的 Swift 代码约束太大时还能降至指针层面进行操作。 本文的目的不是介绍 Swift 语言,所以不会连篇累牍地详细介绍其特性。如果你想详细了解这门语言,看官方文档就够了。这里只会介绍 Swift 的几个亮点,并希望这能吸引人们去尝试它。下面几节将按随机顺序介绍 Swift 的一些亮点,所以排序与它们的重要程度无关。之后,本文将深入介绍可微分编程,并聊聊谷歌在 Swift 上的大计划。 亮点一 Swift 速度很快。这是作者在开始使用 Swift 时所做的第一项测试。作者写了一些短脚本来评估 Swift 与 Python 和 C 的相对表现。说实话,这些测试并不特别复杂。也就是用整型数填充一个数组,然后再将它们全部加起来。这个测试本身并不能透彻地了解 Swift 在各种情况下的速度表现,但作者想了解的是 Swift 能否达到 C 一样的速度,而不是 Swift 是否总能和 C 一样快。 第一组比较作者选的是 Swift vs Python。为了让对应的每一行所执行的任务一致,作者对某些地方的花括号的位置进行了调整。
import time | import Foundation|result = [] | var result = [Int]()for it in range(15): | for it in 0..<15 {start = time.time() | letstart = CFAbsoluteTimeGetCurrent()for_inrange(3000): | for_in0..<3000 {result.append(it) | result.append(it)}sum_ = sum(result) | letsum = result.reduce(0, +)end = time.time() | letend = CFAbsoluteTimeGetCurrent()print(end-start, sum_) | print(end-start, sum)result = [] | result = []}
尽管在这个特定的代码段中,Python 与 Swift 代码看起来句法相近,但运行结果表明这个 Swift 脚本的运行速度比 Python 脚本的运行速度快 25 倍。在这个 Python 脚本中,最外层的循环每执行一次平均耗时 360 μs,相比之下 Swift 的是 14 μs。差别非常明显。 另外,也还有其它一些事情值得注意。比如,+ 既是一个运算符也是一个函数,它会被传递给 reduce(后面我会详细介绍);CFAbsoluteTimeGetCurrent 揭示了 Swift 在传承下来的 iOS 命名空间方面的怪异特性;.< 范围运算符让你可以选择该范围是否包含区间端点以及哪个端点。 但是,这个测试并不能说明 Swift 有多快。要知道 Swift 有多快,我们得将其与 C 来比比看。我也这样做了,但让人失望的是,初始结果并不好。用 C 编写的版本平均耗时 1.5 μs,比我们的 Swift 代码快 10 倍。Uh oh. 不过老实讲,这样比较其实并不公平。这段 Swift 代码并没使用动态数组,因此当数组规模变大时,它会在内存堆中不断重新分配位置。这也意味着它会在每个附加(append)的数组上执行边界检查。为了佐证这一点,我们来看看相关定义。Swift 的标准类型包括整型、浮点数和数组,它们并没有硬编码到编译器中,而是标准库中所定义的结构体(struct)。因此,根据数组的附加(append)定义,我们可以了解到很多信息。知道了这一点后,我的测试方式甚至可以包括预分配数组的内存以及使用指针来填充数组。这样得到的脚本其实也并不是很长:
import Foundation// Preallocating memoryvar result = ContiguousArray<Int>(repeating: 0, count: 3001)for it in 0..<15 {let start = CFAbsoluteTimeGetCurrent() // Using a buffer pointer for assignment result.withUnsafeMutableBufferPointer({ buffer infor i in0..<3000 { buffer[i] = it } })let sum = result.reduce(0, +)let end = CFAbsoluteTimeGetCurrent()print(end - start, sum)
这段新代码耗时 3 μs,速度已经达到 C 的一半,可以说是很不错的结果了。不过为了进行完整的比较,作者继续对代码进行了剖析,以便了解该代码的 Swift 版本和 C 版本的差异究竟可以做到多小。事实证明,作者之前使用的 reduce 方法会毫无必要地间接使用 nextPartialResult 函数执行一些计算,这可以提供非必需的泛化能力。在使用指针重写了这段代码之后,作者最终让这段代码达到了与 C 同等的速度。但是,这显然不符合我们使用 Swift 的目的,因为这种操作本质上就是写更冗长更丑陋的 C 语言。尽管如此,知道在确实需要时可以达到 C 的速度也是一件好事。 总结:使用 Swift,你没法在执行 Python 层面的工作时获得 C 语言等级的速度,但你能在两者之间取得良好的平衡。 亮点二 Swift 采用的函数签名方法也很有趣。它们的最基本形式其实相当简单:
funcgreet(person: String, town: String) -> String {return"Hello \(person)! Glad you could visit from \(town)."} greet(person: "Bill", town: "Cupertino")
其函数签名由参数名加它们的类型构成,没其它多余花哨的东西。唯一不同寻常的是 Swift 需要你在调用该函数时提供参数名,因此你在调用上面的 greet 时必须写下 person 和 town,如上面代码段中最后一行所示。 当我们向其中引入参数标签时,情况还会变得更加有趣。
func greet(_ person: String, from town: String) -> String {return"Hello \(person)! Glad you could visit from \(town)."} greet("Bill", from: "Cupertino")
顾名思义,参数标签就是函数的参数的标签,而且它们是在函数签名中各自的参数之前声明的。在上面的示例中,from 是 town 的参数标签,_ 是 person 的参数标签。对于最后一个标签,作者使用的是,因为 _ 在 Swift 中是一个特殊字母,其含义是:「在调用这个参数时不提供任何参数名。」 有了参数标签,每个参数都有两个不同的名字:一个是参数标签,在调用该函数时使用;另一个是参数名,在函数的主体定义中使用。这看起来似乎有些任性,但会让你的代码更易读。 看看上面的函数签名,基本就像是在读英语。「Greet person from town.」上面的函数调用看起来也同样清楚直白:「Greet Bill from Cupertino.」如果没有参数标签,就有些含混不清了:「Greet person town.」我们不知道这里的 town 是什么意思。这是我们现在所处的城镇吗?还是我们为了面见这个人而将要前去的城镇?又或是这个人原本来处的城镇?如果没有参数标签,我们就必须阅读函数主体才能知晓实际情况,或者采用让函数名或参数名更长更直白的方法。如果你有大量参数,那么情况将变得非常复杂;在作者看来这会导致代码变得更丑而且会让函数名变得毫无必要地长。参数标签更加好看,而且也更容易扩展,而且幸运的是它们也在 Swift 中得到了广泛的应用。 亮点三 Swift 广泛地使用了闭包(closure)。因此,有一些捷径可让该语言的使用更接近人的直觉。这个来自 Swift 的文档的示例展现了这些捷径简洁明了又具有很强的表现力的特性。 我们的目标是将下面的数组向后排序:
let names = ["Chris", "Alex", "Ewa", "Barry", "Daniella"]
如果用不那么地道的 Swift 代码形式,可为数组使用 sorted 方法,并采用一个自定义函数来定义按逐对顺序比较数组元素的方式,就像这样: