Python中的面向数据编程

2020-09-18 10:06:28

许多Python用户剥夺了性能,转而追求软功能,如人机工程学、商业价值和简单性。优先考虑性能的用户通常最终会使用速度更快的编译语言,如C++或Java。

不过,有一群用户被甩在了后面。科学计算界有大量的原始数据需要处理,并且非常需要性能。然而,由于网络效应,以及Python对初学者的友好性对编程不是第一语言的科学家来说,他们很难摆脱Python。那么,Python用户如何才能获得他们的C++和Java朋友所享受的性能的一小部分呢?

在实践中,科学计算用户依赖于NumPy库家族,例如NumPy、SciPy、TensorFlow、PyTorch、CuPy、JAX等。这些库的大量涌现表明,NumPy模式正在做一些正确的事情。在这篇文章中,我将谈论是什么让NumPy如此有效,以及下一代Python数值计算库(例如,TensorFlow、PyTorch、JAX)似乎将向何处发展。

计算的一个令人讨厌的事实是,计算机的计算速度远远快于我们提供数据进行计算的速度。特别是,数据传输延迟是数据设备(RAM和存储)的致命弱点。制造商通过强调提高数据传输吞吐量来掩盖这一弱点,但延迟继续停滞不前。归根结底,这意味着任何链式数据访问模式,其中一个数据检索必须在下一个数据检索进行之前完成,这对计算机来说是最糟糕的情况。

不幸的是,这些最坏情况下的链式数据访问模式非常常见-如此常见,以至于它们有一个您可能熟悉的名称:指针。

指针总是很慢。在80年代和90年代,我们的硬盘驱动器基本上是经过优化的唱片播放器,读取头安装在旋转的盘片上。这些硬盘驱动器有物理限制:磁盘的旋转速度只能如此之快而不会破碎,而且读取头也是机械的,这限制了它的移动速度。磁盘寻道速度很慢,受影响最严重的程序是数据库。数据库处理这些物理限制的一些方法包括:

不使用二叉树(需要\(\log2N\)个磁盘寻道),而是使用具有高得多的分支因子\(k\)的B-树,只需要\(\logkN\)个磁盘寻道。

索引用于查询数据,而不必读取每行的全部内容。

通过从结构数组到数组结构的重组,针对读取繁重的工作负载(例如,跨整个数据集的一个字段的摘要统计数据)进行优化的垂直定向数据库。这最大化了有效磁盘吞吐量,因为没有加载任何无关数据。

今天,计算速度大约比1990年快\(10^5-10^6)倍。今天,RAM大约比1990年的HDD快10^5倍。我发现Raymond Hettinger关于Python内存中dict实现的演变的精彩演讲就像是早期数据库设计的简史,这让我感到既有趣又毫不意外。时间非但没有治愈问题,反而加剧了计算-内存失衡。

在许多高级语言中,原始数据位于包含元数据和指向实际数据的指针的框中。在Python中,PyObject框包含引用计数,因此垃圾收集器可以对所有Python实体进行通用操作。

NumPy数组可以在单个PyObject框中保存许多原始数据,前提是所有这些数据都属于同一类型(int32、float32等)。通过这样做,NumPy摊销了对多个数据装箱的成本。

在我之前对Monte Carlo树搜索的调查中,一个简单的UCT实现表现很差,因为它实例化了数百万个UCTNode对象,这些对象的唯一目的就是保存少量的Float32值。在优化的UCT实施中,这些节点被替换为NumPy数组,从而将内存使用量减少了30倍。

Python的语言设计强制执行异常大量的指针追逐。我提到拳击是间接指针的一层,但实际上它只是冰山一角。

Python处理以下代码没有问题,即使这些乘法调用的实现完全不同。

>;>;>;MIXED_LIST=[1,1.0,';,(';bar';,)]>;>;>;用于MIXED_LIST中的对象:...。打印(obj*2)22.0';页脚(';bar';,';bar';)

如果在超类上定义了__mul__方法,则可能需要额外的指针间接层:必须遍历超类链,一次一个指针,直到找到实现。

属性查找也同样令人担忧;@property、__getattr__和__getattribute__为用户提供了灵活性,只需执行a.b这样简单的操作就会导致指针追逐开销。像A.B.C.D这样的访问模式恰恰创建了链式数据访问模式,这是数据检索延迟的最坏情况。

最重要的是,仅解析对象的代价很高:有一堆词法作用域(本地、非本地,然后是全局)需要检查才能找到变量名。每次检查都需要查找字典,这是指针间接的另一个来源。

俗话说:“我们可以通过引入额外的间接…级别来解决任何问题。除了太多级别的间接性问题。“。NumPy系列库不是通过删除它来处理这种间接的,而是通过在多个数据上分担它的成本来处理。

>;>;>_array=np.arange(5,dtype=np.float32)>;>;>;Multiply_by_Two=同质_数组*2>;>;>;print(Multiply_By_Two)array([0.,2.,4.,6.,8.],dtype=float32)。

共享多个数据的单个框允许NumPy保留Python的表现力,同时最大限度地降低动态性的成本。与前面一样,这是因为NumPy数组中的所有数据必须具有相同类型的附加约束。

到目前为止,我们已经看到,当涉及到指针开销时,NumPy没有解决Python的任何基本问题。相反,它只是通过在多个数据之间分担这些成本来解决问题。这是一个相当成功的策略--在我手中(1,2),我发现NumPy通常可以实现30-60倍于纯Python解决方案到密集数字代码的加速。然而,考虑到C代码在密集数值代码(在科学计算中很常见)上的性能通常是纯Python的100-200倍,如果我们能进一步降低Python开销,那就更好了。

跟踪JIT承诺就能做到这一点。粗略地说,策略是跟踪代码的执行并记录跟踪结果的指针。然后,当您调用相同的代码片段时,重用记录的结果!NumPy在多个数据上摊销Python开销,JIT在多个函数调用上摊销Python开销。

(我应该指出,我最熟悉TensorFlow和JAX使用的跟踪JIT。PyPy和Numba是两个历史较长的备用JIT实现,但我对它们的了解还不够多,无法公平对待它们,所以我向读者表示歉意。)。

跟踪解锁了许多通常为编译语言保留的WINS。例如,一旦将整个轨迹放在一个位置,就可以将操作融合在一起(例如,使用大多数现代计算机常见的融合乘加指令),可以优化内存布局,等等。TensorFlow的Grappler就是这种想法的一种实现。轨迹也可以向后移动,以自动计算导数。跟踪可以针对不同的硬件配置进行编译,因此相同的Python代码可以在CPU、GPU和TPU上执行。JAX可以自动向量化跟踪,为所有操作添加批处理维度。最后,跟踪可以以与语言无关的方式导出,从而允许以Python定义的程序在Javascript、C++或更多语言中执行。

不出所料,这一切都有诱惑力。NumPy可以在多个数据上摊销Python开销,但前提是这些数据是相同类型的。JIT可以在多个函数调用上摊销Python开销,但前提是这些函数调用会导致相同的指针追逐结果。回溯函数来验证这会违背JIT的目的,因此,TensorFlow/JAX JIT使用数组形状和数据类型来猜测跟踪是否可重用。这种试探法必然是保守的,排除了其他合法的程序,通常需要不必要的具体形状信息,并且不保证不进行恶作剧的修补。此外,依赖数据的跟踪是一个已知问题(1,2)。我开发了Autograph,这是一个解决数据依赖跟踪问题的工具。尽管如此,共享跟踪基础设施的工程优势还是太好了,不容忽视。我希望看到基于JIT的系统在未来蓬勃发展,并改善它们的用户体验。

NumPy API专门针对科学计算用户想要编写的程序类型解决了Python的性能问题。它鼓励用户以最小化指针开销的方式编写代码。巧合的是,这种编写代码的方式对于跟踪针对大量并行计算架构(如GPU和TPU)的JIT来说是一种卓有成效的抽象。(一些人认为,由于这种NumPy单一文化,机器学习停滞不前。)。无论如何,构建在类似NumPy的API之上的跟踪JIT正在蓬勃发展,而且到目前为止,它们是访问云上可用的指数级增长的计算的最简单方式。现在正是成为机器学习领域的Python程序员的好时机!