解决方案

10个编写快速运行的Mathematica代码的小诀窍

当我听到人们说Mathematica不够快的时候,我通常会提出想要看一下这段令他们烦恼的代码,然后会发现,其实并不是Mathematica本身的表现不够好,而是Mathematica没有被最优使用。我觉得我应该和大家分享一下我在优化Mathematica代码时首先会看的一些内容。


01.如果可以的话尽量尽早使用浮点数

我最常看到的导致代码变慢的问题是,程序员会不经意地让Mathematica做超出需要的细致的事情。没必要的代数精确是其中最常见的问题。

在多数与数字相关的软件中,是不需要这么精确的代数的。1/3和0.33333333333333是一样的。当你碰到特别严重的在数字上不稳定的问题时这个差异可能会被放大的特别明显,但是,在大多数情况中,浮点数已经足够使用了,而且最重要的是,浮点数运算更快。Mathematica中,任何小于16位的小数都被看作是机器浮点数,所以如果更想要速度而可以舍弃一些精确性的时候,记得用小数(比如,三分之一输入为1./3.)。以下是一个例子,可以看到使用浮点数是精确数运行速度的50.6倍。在这个例子中,两个数字的使用得到的是同一个结果。



在符号运算中也是这样。如果你不是很在意符号式的结果,并且计算的稳定性也不是问题的话,那么尽快使用数值作为替代。比如,求解下面的二项式符号计算时,在使用数值作为替代之前,这个代码可能会让Mathematica生成长达五页的中间符号表达式。



但是如果先用数值替代,那么Solve会使用更快的数值方法。


当用数据列表工作时,使用实数的方法必须保持一致。只要一个精确的数值就可以让整个数据组处于一个更灵活但是缺乏效率的形式中。


02.学会Compile

Compile函数接受Mathematica的代码,并让你预先声明输入参数的类型(比如实数、复数等)和结构(如数值、列表、矩阵等)。这虽然失去了Mathematica语言灵活性的优势,但是可以免于担心类似于“如果参数是符号怎么办?”的问题,Mathematica也可以最优化程序并创建一个字节码在虚拟器上运行。并不是所有东西都可以被编译,且简单的代码可能不会有太大效果,但是那种复杂的低阶数字代码速度可以得到大大的提升。

下面是一个例子:



使用Compile可以比Function的运行速度提高80倍。



但是我们可以在Compile函数中加入一些代码的可并行性质,这样可以生成更好的结果。


在我的双核处理器电脑上,我的运行结果比原本快150倍,如果是多核处理器那么效果会更加明显。

但是要注意,很多Mathematica函数比如Table、Plot、NIntegrate等会自动编译它们的参数,这样的话你使用上述方法可能不会看到任何速度上的提升。


02.5使用Compile生成C代码

另外,如果你的代码可编译,你还可以使用选项CompilationTarget->“C”来生成C代码,调用你的C编码器并将其汇编成一个DLL,并把这个DLL链接回Mathematica,都是自动操作的。在编译阶段,DLL直接在CPU上运行而非Mathematica的虚拟器,所以会更快得到结果。



03.使用内置函数

Mathematica有很多函数。起码半数以上的人可能不会坐下来学习所有函数。所以当我看见有些人会写一些代码而没有意识到其实Mathematica知道怎么做这些操作的时候,我一点也不意外。这种重复操作不仅是浪费时间,而且公司是花钱请程序员来开发研究运行这些操作的最有效方法,所以内置的函数一般是非常快的。

如果你发现有些结果很接近了但是不完全对的时候,此时可以检查选项和参数,通常它们会概括可以覆盖很多特殊用法或者专有应用的函数。

下面举一个这样的例子。如果我有一个一百万2x2矩阵的列表,我想把该列表转换成一百万个包含四个元素的列表,概念上来说最简单的方法是用Map把已经用Flatten扁平化过的数据进行映射即可。



但是Flatten本身知道怎么把整个步骤完成,你只要说明数据结构的第二层和第三层应该被合并而第一层不动就可以了。说明这种细节的内容可能相对来说是比较细致的工作,但是只需要使用Flatten就能完成整个扁平化工作可以让整个进程比你自己手动做这些程序要快将近4倍。



所以记住:在运行代码之前在帮助菜单里先搜索一遍。


04.使用Wolfram Workbench

Mathematica对于某些种类的编程错误容忍度很高——如果你忘记在正确的时候初始化一个变量,Mathematica会以符号的模式顺利运行,而并不会有循环计算或者预料之外的数据类型出现。如果你只想要一个答案的话这个功能是很棒的,但是这也会让你没有得到最优的解答。

Workbench会在几个方面帮助你。首先它会帮你排除程序问题,并把大型的代码项目组织得更好,整齐易读的代码会让程序员更好地写优秀的代码。但是最关键的功能在于分析器会告诉你是哪一行代码用光了时间,而且会告诉你调用这些代码用了多少时间。

看下这个例子,一个很可怕的执行斐波那契数的方法。如果你没有考虑到数列的双重递归,你可能会惊讶计算fib[35]怎么会需要22秒钟(大约和内置函数计算Fibonacci[1000000000]所有208,987,639位数字需要的时间一样)(请看诀窍3)。



在分析器中运行这个代码可以解释这个现象的原因。主要规则被援引9,227,464次,fib[1]的值被请求18,454,929次。

学习代码能做什么,而不是想当然,会让你眼界大开。


05.记住你将来会需要用到的值

这个编程诀窍对任何语言都管用。Mathematica认为你想知道的是这个:



这省去了用任何值调用 f 的结果,这样的话如果再用相同数值调用 f,Mathematica不需要再算一遍。这里你就是用内存换取计算速度,所以如果你的函数要用大量不同数值调用而不太重复的时候这个方法可能不合适。但是如果输入的范围有限,那么这个方法就很有用了。以下就是如何拯救我刚才提到的来解释诀窍3的例子的方法。可以把第一条规则改成这样:



然后速度立刻就可以提升,因为fib[35]现在只需要用主要的规则运算33次。查询之前的结果可以防止循环递归fib[1]的问题。


06.并行

有很多Mathematica的操作都会自动在本地核中并行运行(大部分是代数、图像处理和统计),如果需要手动的话,Compile也可以。但是对于其他操作来说,或者如果你想在远程硬件上并行操作,你可以试用内置的并行编程架构来完成。

有一个这样工具的集合,但是都是为非常独立的任务服务的,比如ParallelTable,ParallelMap,ParallelTry,还有很多。每个这样的小工具都可以自动进行通信、工作管理和收集结果。发送任务和回收结果需要一点时间,所以在减少时间和增加时间上会有需要一个取舍。你的Mathematica有四个计算内核,如果你有额外的CPU可使用的话,还可以通过gridMathematica在此基础上提高这一性能。这里由于我用的是双核电脑,ParallelTable实际将我的运算时间缩少了一半。如果有更多CPU则会得到更好的结果。


任何Mathematica可以做的事情都可以以并行方法运行。比如,你可以给远程硬件发送一个并任务集合,每个任务都在CPU或GPU中编译和运行。


06.5.想想CUDALink和OPENCLLink

有很多Mathematica的操作都会自动在本地核中并行运行(大部分是代数、图像处理和统计),如果需要手动的话,Compile也可以。但是对于其他操作来说,或者如果你想在远程硬件上并行操作,你可以试用内置的并行编程架构来完成。 有一个这样工具的集合,但是都是为非常独立的任务服务的,比如ParallelTable,ParallelMap,ParallelTry,还有很多。每个这样的小工具都可以自动进行通信、工作管理和收集结果。发送任务和回收结果需要一点时间,所以在减少时间和增加时间上会有需要一个取舍。你的Mathematica有四个计算内核,如果你有额外的CPU可使用的话,还可以通过gridMathematica在此基础上提高这一性能。这里由于我用的是双核电脑,ParallelTable实际将我的运算时间缩少了一半。如果有更多CPU则会得到更好的结果。


07.使用Sow和Reap累积大量数据(不是AppendTo)

因为Mathematica数据结构的灵活性,AppendTo不会假设你要追加的是一个数字,因为你要追加的可能是一个文件、音频或者图像等。所以AppendTo必须为所有数据创建一个新的副本,并重新调整架构以适应新追加的信息。当数据累积的时候这个过程会变得越来越慢。(而且构建data=Append[data,value]与AppendTo一样。) 尝试使用Sow和Reap。Sow会舍弃你想要累积的值,而Reap收集它们并一次性在末尾建立一个数据对象。下列范例是等价的:


08.使用Block或With而非Module

Block,With和Module都是本地化构建的工具,但是属性上有些小区别。根据我的经验,95%以上的几率在我写的代码中Block和Module是可以互相替换的,但是Block通常快一点,而在另一些例子中(Block的变量在只读状态的情况下)With会快一些。


09.少用模式匹配

模式匹配很好,可以让项目中复杂的任务变得简单一点。但是它有时会很慢,尤其是像BlankNullSequence这种比较复杂的模式(通常写作“___”)中,可能会花很长时间仔细在你的数据中搜索一些——你作为一个程序员可能已经可以判断的——不存在的模式。如果想要速度的话,那么选择范围更窄的模式,或者不用模式会更好。 比如,下面范例使用了模式,在一行代码中简洁地执行了冒泡排序:



上例概念上很简单,但是比起这个我最开始学习编程的时候就学过的列出步骤的方法来说还是要慢很多:



当然在这个例子中你可以用内置函数(参见诀窍3),这个内置函数会使用比冒泡排序更好的排序算法。


10.尝试不同的方法

Mathematica的一个很重要的优点是,它可以用不同的方式处理同一个问题。它允许你按照你自己的想法编程,而不是为了编程语言的风格重构你的问题。但是,概念上简单和计算效率不是一件事。有时候容易懂的想法可能会需要更多的工作才能实现。

但是另一个问题是,因为Mathematica中最优化和一些绝妙算法都是自动应用的,所以很难预测什么时候Mathematica又会做出另一个绝妙的操作。比如,下例是两种计算阶乘的方法,第二种比第一种快10倍。



为什么?你可能会猜可能Do的循环很慢,或者所有这些任务缓存都需要时间,或者可能第一次执行的时候有什么东西出了问题,但是实际的原因很难预料到。Time有一个很聪明的二元分离的小技巧,可以在当你有大量整数参数的情况下使用,即将循环将参数分成两个更小的乘积(1*2*…*32767)*(32768*…*65536),而不是把这个参数从第一个用到最后一个。当然要做的乘法数量还是一样,但是不会再包括数值非常大的整数,所以平均来说,运算的速度会更快。在Mathematica中有很多这样隐藏的小魔法,而且每次新版本发布都会有更多的小技巧加入。 当然最好的方法还是使用内置函数(又说到诀窍3了):



Mathematica可以做非常高级的计算,而且有强大的功能和极高的精确性,但是这两者并不总能兼得。我希望这些诀窍可以在快速编程、快速执行和精确结果的冲突诉求中对你有些许帮助。