通过优化代码来降低运行时间是一件十分有意义的工作。然而,大部分情况下,优化是编写软件的最后一个步骤,并且通常是在不得不优化的情况下进行的。如果你的代码可以在能够接受的时间范围内运行完成,那么优化并不是必需的。
鉴于此,为什么我们还要优化代码呢?作为数据科学家,我们现在面对的是越来越大规模的数据集,在每个元素上进行的操作可能要重复数十亿次才能得到最后的结果。如果代码质量很低,可能需要花费数天才能完成运行。此外,大部分科学计算和数值软件是计算密集型而不是网络密集型的任务。鉴于数据计算中使用的模型也越来越复杂,每一点的性能下降都会产生严重的后果。那么优化代码还有什么其他的原因吗?原因如下。
本小节将简要介绍软件优化的步骤。
下面的内容介绍了我们将要使用的代码优化步骤。
1.建立现有代码的基线性能(执行时间、内存消耗、峰值I/O带宽等)。
2.确定性能目标以及系统的上限。以终为始是高效能人士的七个习惯之一,优化也是同样的道理。代码需要执行多快?完成任务可接受的最长时间是多少?软件最大可以占用多少内存?计算结果需要多准确?结果需要怎样的保真度?
3.建立测试和度量环境,可以帮助你迅速测量相关的性能指标。如果能够更容易地度量每一行代码的性能,那么就可以更快地优化代码。如果度量的过程十分痛苦并且总要去记一些你会忘记的命令,优化的过程就会变得痛苦而缓慢。
4.记录当前代码的所有参数和状态快照。
5.利用剖析器寻找代码瓶颈。
6.从最主要的瓶颈入手来解决性能问题。鉴于代码的复杂度,每轮测试只解决一个问题是一种安全的方法。
7.利用剖析器分析修改后的代码,检查结果是否有变化。
8.跳回第4步,尽可能多地重复进行。
和粉刷房屋一样,你需要做好充足的准备才能取得成功。在刷子触碰到墙壁前,你需要挪走墙边的所有家具并给它们盖上布。地板通常被一块干布保护起来。画、架子和其他挂载在墙上的一些东西都必须取下来,钉子也必须拔掉。还需要用湿布擦拭墙来除掉灰尘,并处理那些需要修补的地方。接下来,你需要封住那些不需要被粉刷的部分,如门边、窗边、电源插座。所有这些都做好后,真正粉刷墙只需要花很少的时间。优化也和这个过程类似,你必须创建好一个利于优化进行的环境。因此上面的第3步“建立测试和度量环境”是十分重要的一步。
在优化代码的过程中,你会不断停下来测试大大小小的代码变更。一些工作正常,一些会使代码无法工作。一些会提高性能,而另一些不会。当你不断停下来进行测试时,能够恢复四五次之前性能最好的可工作代码变得异常重要。同样重要的是,要记住每次变更所带来的性能影响。当你测试了一组代码变化后,你需要知道保留哪些变化。Git、Mercurial 和其他一些代码版本管理工具都是你的好帮手。
在第6步中,这种处理瓶颈的顺序通常是从简至难。如果主要的瓶颈需要重写大量的代码才能解决,那么可以先去解决那些只需要一行代码或者很少的变更即可解决的次要性能瓶颈。
每一行可能带来性能提升的代码同样可能引入新的问题。因此在优化代码时,不要去做和优化性能无关的事情。最糟糕的优化可能会带来难以检测的问题,甚至代码行为的不一致。同样需要注意的是,优化代码的过程可能会使你自己和需要接手代码的人越来越难读懂代码。
在优化数据科学项目的过程中,查看整个分析过程、了解每个阶段所需要消耗的时间以及它们之间的关系是十分重要的。
让我们把问题简化成减少软件的执行时间,且只考虑使用一种特定的语言来实现这个软件。在这里,我们不考虑处理大数据集,也就是说,将数据规模从生产数据库规模降到简单分析用的数据。
从更抽象的层次来说,执行时间只和代码本身以及硬件条件相关。如果想要降低运行时间,要么修改代码,要么升级硬件,或者两者同时进行。
对于优化来说,我们必须时刻记住自己的目的是什么:必须达到何种程度的优化或者软件需要运行多快。
将运行时间降为原来的二分之一和降低一个数量级是两种完全不同的优化方式,通常后者需要代码发生根本性的变化。
剖析器(一种可以提供其他软件运行时信息的软件)可以帮助你找到运行最慢的代码行或者代码块。通过观察,你可以发现存在某种特定的代码类型、代码模式或者问题域,可以提高代码的速度。
1.留心任何的循环,尤其是嵌套循环。嵌套的层次越多,代码通常越慢。
2.注意那些运行速度慢的数学操作,如平方根、三角函数以及除加减之外的其他运算。
3.减小关键数据结构的大小。
4.选择经常使用的变量的精度(float、double、int、uint8等)。
5.避免无用的运算。
6.简化数学方程式。
第一步要检查循环,因为循环中的代码将会执行多次,如果有循环嵌套,那么执行的次数又会大幅上升。那些被反复执行的代码是性能优化的过程中需要重点关注的地方。此外,对于R和Matlab等一些特定领域语言,语言的特性导致它们的循环性能很差。在这种情况下,用另一种编程结构(R里的函数编程方式或者Matlab的向量化表达方法)代替循环会带来性能的提升。
在第二步中,对于计算密集型任务,通过定位计算缓慢的数学操作,通常能发现显著提高执行速度的机会。自然界中存在大量的现象需要用平方根来解释,如常见的计算n维空间内两点间欧氏距离。不幸的是,求平方根的操作开销很大,相比于基本的加减乘除以及逻辑判断,求平方根的操作要慢很多。而乘除相比于加减和逻辑判断也要慢很多。
一般来说,你手上的T1-85或者HP 48G计算器上,除了加、减、乘、除按钮,其他数学函数都要远远慢于这4个基本操作。这就意味着你必须考虑如下函数。
之前计算两点间距离的代码中大量使用了条件判断语句,如下面的代码。
if distance_between(point1, point2) > 1.0 ...
或者将代码写得更直接一些:
if square_root( (x1-x2)^2 + (y1-y2)^2 ) > 1.0 ...
这时优化的机会就出现了:如果在不等式两端都做平方,那么开销昂贵的求平方根方法就可以被去除了。但是需要考虑平方根的一些特性,以及在1、−1和0上的平方操作。平方的过程中,负数会变成正数,小数会更接近零。当处理距离和其他一些物理量时,负数通常都不是一个问题。
作为一个通用的法则,数据结构越小,计算速度越快。越小的数据结构,越能很好地利用分层的存储结构,数据越接近CPU。理想情况下,所有的计算数据都已经存储在寄存器上,无须从L1、L2甚至更低的缓存中获取,更不要说去系统内存中获取,否则会导致速度明显下降。而面向对象的语言通常在这方面有缺陷。每个数据结构都必须绑定很多无须用到的方法,导致数据结构变大,以至于不得不在每次迭代过程中从系统内存中获取数据。
从技术上来说,第四步可以减小数据的体积,但是这也需要针对具体情况来分析。一些语言默认设置整数和浮点数都为64位。对于一些整数来说,并不需要64位这么大的空间。事实上,大部分情况下,8位的整数就可以满足一般需求。有些时候不需要表示负数时,无符号整数是一个更优的选择。
很多浮点数的计算事实上不需要如此高的精度。16位的整数可不可以?32位的整数可不可以?通过这种方式可以将数据体积降为原来的二分之一或者四分之一,并带来明显的性能提升。
对于第五步,寻找那些可以在分支中执行的代码,尤其是需要大量计算的代码。如果一个昂贵的运算并不是总要去执行,那么把它放入一个分支中,只在必要的时候运行。
在编写计算复杂的代码时,大部分代码都写得很简单明了,使得作者可以一次就得到正确的结果。这就意味着可能会有一些不必要的复杂计算、速度慢的代码存在,尤其是在条件判断附近。如果你使用的是编译型语言,编译器可能会对代码进行重新排列,但是最好还是不要过于依赖它(动态语言和JVM上运行的语言,在运行期很难判断它们究竟会做什么)。
对于第六步,大部分数学表达式写出来都是给人看的,而不是为了让现代计算机软件和硬件可以很好地运行。简单修改一下等式,去除一些项或者改写一些项都会带来性能的提升。例如,将乘法转为加法,除法转为乘法。对于那些需要执行上亿的操作来说,这些小小的改动会带来时间上巨大的节省。加减预算要快于乘法,而乘法又要快于除法。
我们将要优化的是一段计算分子可接触表面积(Accessible Surface Area,ASA)的 Python代码。ASA量化了分子可以被溶液所接触的表面面积,这是一个在生物和生物化学中广泛使用的量值。对于本小节来说,你不需要对ASA有十分深入的了解。如果你对此很有兴趣,强烈推荐你看一下Bosco K.Ho关于ASA的博客和代码。他也是本小节中原始代码的作者。代码出于清晰和正确的目的,并没有过多考量性能。
出于优化的目的,代码将被集成在一个网页应用中。当用户上传数据时,分子的ASA将会被计算。由于所有计算过程都是同步的,代码执行时间越长,用户等待时间越久。
本小节,我们将阅读代码 asa.py源文件中的核心部分,对代码进行一个大概的了解,并发现潜在的性能瓶颈。
在文本编辑器或者IDE中打开asa.py文件。这个文件中包含的代码可以在本书的代码库中找到。
下面的步骤将会带你浏览我们将要优化的代码。工作原理部分将详细介绍代码是如何工作的。
1.在文本编辑器中浏览asa.py。注意哪些包被导入、哪些函数被定义,阅读代码中的注释。
2.移动到main()函数,浏览之后一连串的函数调用。思考main()函数在asa.py中的作用是什么。
3.深入阅读每个函数,从最重要的calculate_asa函数开始。
asa.py的main()函数处理包含需要被分析的分子信息的文件及其他命令行参数。当利用 molecule.py中的方法导入分子结构后,再调用calculate_asa函数来处理之后的所有计算。
在文件的顶端,找到第一个函数generate_sphere_points(),这个函数可以计算在指定半径范围内的等距点的个数,并将等距点以三元组列表的形式返回。
该函数利用螺旋黄金分割算法来生成平面上的等距点。此外,还有几种不同的方法来处理同样的问题(在参考资料一节会给出相应链接)。点的个数是这个函数唯一的参数,这些点用来表示一个表面。越多的点数,越能精确地表现这个表面。如果函数接受无数个点,那么就可以完美地表示这个表面。
让我们从一些细节入手性能优化。点的列表在初始化时为空,每轮迭代都会在尾部插入新的点。在快速写代码的过程中,体积不断增长的数据结构通常会导致严重的问题。
其次,range(int(n))产生了一个有int(n)个元素的列表,元素的值为0至int(n) − 1。这种情况下,需要在内存中分配整个拥有n个元素列表的内存空间。当n很大时,需要消耗大量时间进行内存分配。最好利用生成器的方式完成此类工作,比如用xrange函数代替range函数。xrange只需要分配一个xrange对象的内存空间,并且只在真正需要一个值的时候生成一个数字。此外,xrange是用纯C语言实现的。
这些是针对3.0版本前的Python。3.0版本后,range实现为一个迭代器。
最后也是最重要的一件事是,Python的for循环对性能消耗很大。我们希望能够把循环从解释器中移除,利用编译后的C代码来实现。
find_neighbor_indices函数接收3个参数。
这个函数返回在特定原子探针距离内的原子索引的列表。
代码中有一个遍历所有索引的循环。在循环内部,neighbor_indices变量随迭代不断增长,vector3d.py中的pos_distance函数不断被调用。在函数中,我们看到首先计算两点p1和p2的距离的平方,然后取平方根的操作,如下面的代码所示。
def pos_distance(p1, p2): return math.sqrt(pos_distance_sq(p2, p1)) def pos_distance_sq(p1, p2): x = p1.x - p2.x y = p1.y - p2.y z = p1.z - p2.z return x*x + y*y + z*z;
注意,math.sqrt函数是个计算开销十分大的函数。
calculate_asa (atoms, probe, n_sphere_point=960)函数是主要的工作函数,用来处理和协调相关的计算。它接受3个参数。
最重要的一个参数是用来计算ASA的原子列表。每个原子都有x、y、z三个坐标和一个相关的半径。Atom类在molecule.py文件中被定义。
函数一开始通过generate_sphere_points生成一组表面的点并初始化空列表。接下来是一个三层循环,最外层的循环遍历原子列表中的所有原子,对于一个典型的分子来说可能会有上百甚至上千个原子。在这一层for循环中,对于每个原子调用find_neighbor_indices来生成最邻近的原子列表。
接下来进入第二层循环,遍历sphere_points中的每一个点。如果使用默认的960个节点,那么循环将有960次迭代。对于平面等距点结合中的每一个点,我们加入当前原子的中心坐标。
然后就是最内层循环,遍历所有的邻近原子。对于每个邻近原子,我们比较test_point到邻近原子中心的距离。如果两点之间在一个特定的距离内,我们就认为test_point不能被溶剂分子接触。
用另一种方式来解释,内部两层循环利用预先生成好的表面等距点生成测试原子。然后检查每个点,看看是否存在有一个邻近原子在指定的距离内。如果存在,那么表面上的这个点就被认为是不可被接触的。
原子的可接触区域即为被邻近节点所阻断的原子周围表面部分。
三层嵌套循环和大量的计算意味着有很大的性能提升空间。
本文节选自《数据科学实战手册》(R+Python)
这本书是基于R和Python的数据科学项目案例集锦,内容涵盖了基于数据科学的所有要素,包括数据采集、处理、清洗、分析、建模、可视化以及数据产品的搭建。案例包含了汽车数据分析、股票市场建模、社交网络分析、推荐系统、地理信息分析,以及Python代码的计算优化。通过手把手的案例解析,令读者知其然并知其所以然。
业界的数据分析师、数据挖掘工程师、数据科学家都可以读一读。想要了解实际工作中如何用数据产生价值的在校学生,或者对数据科学感兴趣的人也值得一读。