Python 的语法很灵活,融合了很多 其他 语言中令人觉得方便的特点。然而 Python 的优势同时也隐含了其劣势。蚂蚁研究员王益在工业系统中对 Python 的亲身体会,更加深刻地了解到了 Python 的局限,而 Go+ 是弥补方案里最靠谱的。那么 Python 有哪些不足? Go+ 又是如何能弥补的?本文分享 王益 对 Go+ 补全 Python 的局限上的相关看法和尝试。
不久前许式伟(江湖人称老许)的 Go+ 项目在 Hacker News 上掀起了一阵风潮[1]。我一见倾心,参与贡献。最近老许和社区组织了一个视频交流,拉我跟大家说说为啥关注 Go+ 以及图个啥。在直播交流后,根据弹幕反馈,以及两位好友 ——洪明胜(TenosrFlow Runtime 负责人)以及王玉(沈雕墨)的建议,做了修改。
我做分布式深度学习系统十三年了,尤其是 2016 年徐伟老师让我接替他作为他原创的PaddlePaddle 项目的负责人之后,在工业系统中对 Python 的亲身体会让我对其局限了解愈深。而 Go+ 是我见过的弥补方案里最靠谱的。
我期待 Go+ 对标 Python,补全 Python 的不足,并且在此基础上有一个类似 numpy 的项目(姑且称之为 numgo+ 吧)用来支持张量(tensor)运算,满足数据科学的需求;在 numgo+ 之上再构建一个类似 PyTorch 的深度学习基础库(姑且称之为 GoTorch 吧)。如果可以,进一步成为深度学习编译器生态的一种前端语言。
我现在在蚂蚁集团工作,负责一个开源 SQL 编译器 SQLFlow —— 把扩展语法以支持 AI 的 SQL 程序翻译成 Python 程序。同事们说,如果 Go+ 这套生态能成熟起来,很乐意让 SQLFlow 输出 Go+ 程序。
很多读者估计觉得我瞎说八道 —— Python 如此如日中天一般火热的语言,何须“补足”?
Python 的语法很灵活,融合了其他很多语言令人觉得方便的特点。比如,和 C++ 一样, Python 允许重载操作符,numpy 的作者于是重载了算数操作符来做张量运算。和 Lisp 一样,Python 的 eval 函数递归地实现了 Python 解释器,可以解释执行 Python 表达式,所以 Python 程序可以生成自己。
这样的灵活性允许程序员随心所欲,因此特别适合探索性工作。比如研究生们用 Python 做科研;数据科学家们用来替代之前各种昂贵的商业化系统;在随后诞生的深度学习领域,Python 也迅速蓬勃发展起来。
Python 的优势同时也隐含了其劣势。 我亲身感受的痛点有二。
语法灵活的另一种说法是:一个程序有多重写法。现代软件工程里没有孤胆英雄,全靠大家合作。多种可能的写法往往意味着团队容易在 code review 时吵架 —— 而且难以平息,因为不一定有客观选择标准。很多其他语言也有类似问题,比如 Java。解法是,社区里定一些设计模式(design patterns),程序员写程序前先看看有没有可以套用的设计模式,如果有,则遵循之。所以 Java 程序员除了学习 Java 语法,还要学习设计模式。C++ 也有类似的问题。解法之一是 Google 定了一套 code style —— 哪些语法可以用,哪些不许用 —— 按照 Rob Pike 的解释,允许用的部分语法挑出来,就是 Go 的设计初衷。Python 太灵活,以至于 code style 都没法定义得和 C++ 的一样细致 —— PEP8 几乎只是说说排版要求,对语法的选用几乎没有限制。Python 也没法定义模式 —— 太多了,写不完。
Python 为了灵活采用动态类型,所以我们看一个 Python 函数,必须得细读其代码,否则都不知道它有没有返回值,以及返回值是啥。Python 也有语法扩展,要求编程者指明输入输出的数据类型,不过用的人不多 —— 毕竟大家都是冲着“灵活”来的;要是限制灵活性,那就真不如用静态类型语言了。这个结果是,每个 Python 函数都不能太长,否则看不明白了。可是 Python 程序员就是冲着灵活性来的,要的就是信马由缰的感觉,管你懂不懂呢,我自己明白就行,反正发完论文就毕业了。拆分函数细化粒度?不可能的,这辈子都不可能的。
有没有写的很好的 Python 代码呢?有的。比如 Google Tangent。这是一个很小众的项目。作者也只有两个。其代码结构清晰 —— 每个函数基本都在十行代码之内,代码和注释一样长,所以很好懂。不过这也和 Python 用户众多的印象相悖了。我在负责 PaddlePaddle 项目的时候,除了自己努力学习和总结 Python 的模式,也配置 CI 调用各种工具做源码检查,然并卵,这些工具没有智能化到可以自动注释代码,也不会自动拆分太长的函数定义。
Python 的语法丰富、灵活性强,所以解释器写起来很复杂,要优化性能也很难。相比之下,Go 语言语法简洁,表达能力远胜于 C 但是 keyword 总数少于 C,这种简洁使得 Go 程序的性能优化比较容易。在 Go 诞生后几年,Go 编译器对代码的性能优化水平就快速接近 GCC 对 C++ 程序的优化水平了,而 C++ 和 Python 一样,语法丰富,所以编译器里的代码性能优化功能很不容易开发。
有人尝试写 Python 的编译器来代替解释器,从而在程序执行之前先做性能优化。但是 Python 语法比 C++ 更灵活,以至于几乎没法写一个完全支持 Python 标准语法的编译器出来。几个尝试因此作罢。目前的普遍的做法是解释器来做执行时优化(JIT compilation),因为有 runtime 信息,所以相对编译器更容易一些。
在 AI 领域,深度学习训练非常消耗计算资源。TensorFlow 的图模式的解法是:用户写的 Python 程序在执行时并不真的做训练,而是把训练过程输出成一个被称为”计算图“的数据结构,交给 TenosrFlow runtime 这个“解释器”来执行。只要保证 TensorFlow runtime 的执行效率,即可不受 Python 解释器效率的限制。
TensorFlow 图模式用心良苦,也画蛇添足 —— 源程序、各层 IR、以及 binary code 是一直以来人们用来描述计算过程的表达方式,TensorFlow 项目早年间发明的计算图重复造了个轮子,而且造得不专业 —— 图难以表达 if-else、循环、函数定义和调用,更别提 closure、coroutine 和 threading 这样的高级控制流结构了。人工智能工程师的非专业编译器设计让 LLVM 的作者 Chris Lattener 掩面而笑,于是他尝试用 Swift for TensorFlow 替换 Python 作为前端语言,用 MLIR 代替 TensorFlow 中的“计算图” [2] 。
我在负责 PaddlePaddle 期间为了验证 Paddle Fluid 的能力,和我的同事陈曦一起做了一个无人驾驶船,尝试用 Fluid 写 immitation learning 方法,让船能学习人类驾驶员的驾驶技术,详情请见 系列博客[3]。可是如果我们把跑 Python 程序的 MacBook Pro 带上船则太费电,而嵌入式的设备上又不适合跑 Python 写的训练程序。如果每次停船后上传数据到服务器训练,那么船向人学习迭代的进度就太慢了。
为此,当时另一位同事杨杨写了 Paddle Tape,用 C++ 实现了 PyTorch 的自动求导能力,结合 Paddle Fluid 积累的众多用 C++ 写的基本计算单元(operators),Tape 完全是一个 C++ 实现的深度学习系统系统,和 Python 没啥关系了。
2019 年初,我的朋友洪明胜在 Google 负责 Swift for TensorFlow 项目,这也是一个 AI 基础架构去 Python 化的尝试。他当时拉我给 Chris Lattener 的团队分享了 Paddle Tape 和无人船的故事,并修改了幻灯片[4]。
我在蚂蚁集团负责的一个开源分布式深度学习训练系统 ElasticDL,尝试过调用 TensorFlow graph mode、eager execution mode、PyTorch、和 Swift for TensorFlow,很受 Swift for TensorFlow 的设计理念以及和 Python 生态共荣的策略的启发。
以上尝试提醒我,语言的选择标准必须包括:语法清晰简练和语法稳定容易学习。也希望语言的使用者是比较有探索精神的一个群体。Go+ 及其基于 Go 社区的用户群体刚好符合这些条件。
在 Go+ 出现之前,也有把 Go 用于数据科学的尝试,也有用 Go 实现的张量运算库(比如 gonum),但是用起来都不如用 numpy 的 Python 程序简练,很直接的一个原因是 Go 的常量需要指定数据类型,而 Python 的则不用。我 写了几个对比 [5] 。
用 Go 定义一个 ndarray 类型的常量,用户需要写:
x :=numgo.NdArray( [][]float64{ {1.0, 2.0, 3.0}, {1.0, 2.0, 3.0}})
而用 Python 是:
x = numpy.ndarray( [[1.0,2.0, 3.0], [1.0,2.0, 3.0]])
有了 Go+ 来自动推导数据类型,写法就和 Python 几乎一样了:
x :=numgo.NdArray( [[1.0, 2.0, 3.0], [1.0,2.0, 3.0]])
更进一步,老许加的一个 comment 解释 Go+ 准备支持 MATLAB 的张量定义语法。这样一来,这个程序就更简单了:
x :=numgo.NdArray( [1.0, 2.0, 3.0; 1.0, 2.0, 3.0])
类似的便捷的语法改进在 Go+ 已经积累了不少,例子在[6] 。这些语法扩展足以极大简化数据科学编程。
而 Go+ compiler 负责把利用这些语法糖写作的 Go+ 程序翻译成 Go 程序。这样可以和其他 Go 语言写的库一起编译,从而复用 Go 生态里的代码。
复用 Go 生态是 Go+ 语言的一个长项。在 Go 的发展过程中,已经积累了不少科学计算的基础技术,比如实现张量的 Go 数据类型的封装。这些数据类型的计算也有高效的 Go 实现,部分缘于 Go 程序可以方便地调用 C/C++ 程序,包括科学计算领域里久经考验的基础库如 LAPACK,甚至 NVIDIA GPU 的接口库 CUDA。值得注意的是,这些基于 C/C++ 的基础库也是 Python 的数据科学生态的基础,所以本文的标题是 Go+ 补全 Python 生态。
上文提到了深度学习技术。这是 Python 被广泛使用的另一个领域,和数据科学有自然的联系,比如 PyTorch 和 TensorFlow 的 tensor 数据结构和 numpy 的 ndarray 一样。而在深度学习领域,编译器是最新的主流研究方向。
Go 社区里目前后台系统开发者居多;视频直播时,有听众在弹幕里说自己不是 AI 工程师,不关注 AI。如果真的这么想,恐怕不只是技术理想问题,而且是对饭碗不负责任了。
后台系统和 AI 系统之间的界限越来越模糊,因为后台系统指的是互联网服务的后台系统;而整个互联网经济建立在用不眠不休的服务器取代人来服务大众,而 AI 是这个逻辑成立的基础,详见我的一篇老文[7] ,例数了最近二十年被 AI 技术淘汰的人类职业。
而且这个界限在不久的将来会彻底消失,因为随着 online learning、reinforcement learning、 imitation learning、federated learning 技术取代 sueprvised learning 成为互联网智能(包括传统的搜索、广告、推荐,也包括新兴的无人驾驶和金融智能)的主流技术,AI 系统将不再能被分为训练和预测两部分,也不再由 AI 工程师负责前者,而后台工程师负责后者了。
在 AI 领域里,深度学习超越传统机器学习的一个重要原因是:传统机器的每一个模型(可以理解为对知识结构的描述)往往对应一种甚至多种训练算法;而深度学习里,几乎所有模型都用一种算法 stochastic gradient descend(SGD)或者其大同小异的变种来训练。这样,基础架构工程师负责训练系统的开发;模型研究人员复用之,大大减小了科研的工程负担,提升了模型研发的效率。
深度学习系统的核心问题在于 autodiff,这是 SGD 算法的数学特点决定的。SGD 算法通过交替执行前向计算过程(forward pass)和反向计算过程(backward pass),即可从训练数据归纳出模型的参数。模型加参数就是知识。这里的工程挑战在于模型研究者在定义模型的时候,就附带描述了前向计算过程,但是反向计算过程很难由人来描述,最好有一个程序自动从前向计算过程推导出反向计算过程。这个自动推导被称为 autodiff。
目前有两种 autodiff 的策略。第一种在运行时推导,也被称为 dynamic net 和 tape-based approach。基本思路是不管前向计算过程有多复杂,哪怕包括 if-else、循环、函数定义和调用、甚至 coroutine 和 multithreading,只要把依次执行的基本操作(operator)记录下来,到一个 tape 里,那么反向计算过程就是回溯这个 tape 里的记录,并且依次调用每个 operator 对应的求导数 operator(gradient operator)。这是 PyTorch、TensorFlow eager execution、以及 Paddle Tape 采用的策略。这种策略和编译器关系不大,和 JIT compilation 有点关系。
另一种策略是运行之前推导反向计算过程,为此需要引入一个专门做 autodiff 的编译器。TensorFlow graph mode、Caffe/Caffe2、Paddle Fluid、Google Tangent、Julia、Swift for TensorFlow 用的是这个策略。编译器一般来说是把源语言描述的源程序翻译成目标语言描述的目标程序。但是前三种技术偷懒了,没有引入源语言,而是让用户通过调用 Python library 来描述前向计算过程。Google Tangent、Julia、Swift for TensorFlow 分别让用户用 Python 语言、Julia 语言、Swift 语言来定义函数,从而描述前向计算过程,并且能把前向计算函数翻译成反向计算函数。
严格地说,Julia 的作者实现了多种 autodiff 方案:有运行时的、也有编译时的、也有二者混合的。明胜在帮我修改此文时提醒:
For a different vision,where the same language is used to both implement kernels and construct+executeprograms/graphs based on the kernels, see [8].
这里的 kernel 指的是深度学习基本操作单元 operator 的实现。
编译时和运行时 autodiff 这两种策略,也都适用于 Go+,而且并不妨碍 Go+ 复用现有技术。就像数据科学领域应该复用 LAPACK 这些基础库,深度学习领域也应该复用基础的 operators 和 gradient operators。
运行时用 tape 实现 autodiff 的策略的实现更简单。我记得杨扬用一个星期时间就开发了 Paddle Tape。而编译的策略复杂很多。Paddle Fluid 二十多人在 TensorFlow 团队 Yuan Yu 老师的工作[9] 的 基础上,用了好几个月的时间,才搞定 if-else、循环、函数定义和调用的 autodiff。
这些尝试提醒我们复用社区核心技术的重要性。比如,用 MLIR 代替计算图从而能描述更复杂的控制流 —— 计算图肯定没法描述 goroutine 和 select。用 TVM 作为编译器后段(backend),用深度学习技术学习如何优化深度学习程序。所有这些技术的输出,都是对基本 operaotor 的调用。从这个角度看,之前深度学习技术生态积累的 operators 类似 built-in functions。这也是洪明胜在修改此文时反复提醒的。
希望不久的将来,Go+ 可以作为一种新的深度学习前端语言,与 Python、Julia、Swift 并列,共同复用更底层的 IR、编译器后段、以及基本 operators。
我理解未来 Go+ 项目的核心战术工作是:在维持 Go 的语法简洁性的本色之上,合理准入简化语法 —— 不要像 Python 和 C++ 那样融入太多灵活性,同时在 Go 的极简语法规范之上,适当地更加灵活。
此外,通过社区合作开发 numgo+ 和 GoTorch 这样的探索性项目,丰富技术生态是社区的战略方向。甚至更进一步,成为一种深度学习编译器的前端语言,以复用多年来社区沉淀的深度学习底层计算技术。
最后,感谢老许和 Go+ 的核心贡献者柴树杉和陈东坡、Go 社区的杰出贡献者 Asta Xie、以及我的同事 ONNX 社区核心贡献者张科校阅。
领取专属 10元无门槛券
私享最新 技术干货