5月29日
在我们的领域,MATLAB是进行实验的最主要的工具之一。我听到过很多抱怨说MATLAB很慢,可是这往往不是MATLAB的问题,而是因为自己程序没有写好。MATLAB是我非常欣赏的一种语言,堪称灵活易用和高效运算的结合典范。
作为解释语言,MATLAB进行高效运算的秘诀有几个方面:
(1) JIT即时编译技术。Matlab在第一次加载运行一个函数的时候,会在后台对它进行某种程度的编译和优化,使得后续运行更快。因此,除了第一次运行需要进行语句解释之外,后面运行的其实是已经放在内存中的经过优化的中间码。所以,很多时候我们可能会看到第一次运行一个新函数,比它后面几次的运行明显慢一些。不过目前的JIT技术还不是非常成熟,和标准的编译语言相比还有相当差距,仅凭这个MATLAB还不能称为高效。
(2) 向量化(Vectorization)。这是MATLAB最著名的特色。向量化配合经过高度优化的数值运算引擎,是其高效运算的最重要的基石——很多MATLAB的使用者都明白这一点。能够转化成矩阵操作的规则运算过程,使用MATLAB计算远比自己手工用C/C++实现高效。我自己做过很多次对比profile,MATLAB在关键的核心运算上的实现可以比自己用C/C++按照标准的routine进行实现快几十倍。
其实这不完全是MATLAB的功劳,其实MATLAB是建基于BLAS和LAPACK等的基础数学运算包的基础上的。Intel和AMD都发布了这些东西的vendor-implementation,并且针对各自的CPU进行了大量的优化,这非个人之力所能企及。因此,我从来都强烈不建议个人用C/C++去实现数值运算的关键代码(学习数值分析课者除外)。
不过,对于向量化这个事情,也不宜极端化,下面的一些例子说明在哪些时候,for-loop比vectorization更适合:
- (a) 粗粒度的算法流程控制。比如,你要实现一个迭代算法,循环做一个事情直到收敛。只要循环几次或者几十次,但是每个循环内部要进行大量的复杂运算,那么你就没有必要费心把这层循环给vectorize掉了。除非收敛结果有某种close-form的解析解。
- (b) 如果向量化可能导致产生巨型矩阵,则使用前要三思!很多情况下,向量化是一种用空间换时间的行为,就是通过把数据组织成某种方式,从而使得内建的高效引擎能得以应用。但是,有些时候要处理大量的数据,可能导致其组织过程需要耗费额外的数百兆乃至千兆内存空间,那么这有可能造成效率的不升反降。原因有几个方面:
第一,数据组织过程也是需要时间的,最起码它也需要大量的操作进行密集的内存读写和用于定位的偏移量计算。这方面增加的时间 vs. 向量化节省的运算时间,孰轻孰重,需要衡量。
第二,分配额外的大块内存是一件非常耗时的事情,它可能导致虚拟内存的使用,那么当对这块矩阵进行读写和计算时可能涉及频繁的内存与外存交换区的I/O,这回造成效率的严重下降。我一直不赞同在Out of Memory的情况下,通过增大虚存来解决问题,这样,即使你勉强让程序能继续运行下去,运行时间也会变得极为缓慢——这时应该做的是对程序进行重新思考和设计,降低其对内存的耗用。
第三,vectorization有些时候还可能增加实际运算次数,这往往出现在不适合的向量化过程中。这样,即使你通过生搬硬套的向量化过程让操作变成矩阵运算,但是增加的无用计算使得即使是更高效的引擎也无法挽回你的损失了。
说了这么些,不是想劝说从向量化的道路退回去,而是提醒在做一些事情的时候,要考虑得周全一些。
(3) Inplace Operation。这是一个很有趣的事情,MATLAB的对象管理策略是Copy-on-Write,就是说你把一个矩阵传给一个函数的时候,会先传引用,而不产生副本,而当函数要对这个矩阵修改的时候,才会制造出它的副本出来,让函数去修改。这样听上去很完美,既保护了输入矩阵不被改变,又避免了大量无谓的复制。不过,这个策略真的没有缺陷么?可以看看下面的例子
A = rand(2000, 2000);
for i = 1 : 1000
A = f(A);
end
function A = f(A)
A(1) = A(1) + 1; % a temporary copy of A is produced for modification
return; % the modified temporary copy is assigned to A
在上面的代码中,只是想对A的第一个元素做1000次加法,结果却导致了对整个有四百万元素的大矩阵做了1000次副本复制!而且这些副本其实没有用,只要f(A)直接对A的原矩阵进行修改,这些巨大的浪费就能避免。你可能跟我argue说,这里完全可以写成A(1) = A(1) + 1000,不用这么搞。我想说明的是,我只是想举一个简单例子说明,Copy-on-write的策略为什么可能造成巨大的效率浪费。其实,很多小修改没法像上面那么容易合并。
以前,为了解决这个问题,f函数的作者需要自己写一个mex function来避开MATLAB的保护机制,直接改写A。后来matlab自己也意识到这种策略在实际中的问题,于是他们改写了解释器,采用了一种办法:A = f(A, ...) 当它发现这样的定义和调用的时候,它会很智能地了解到你其实是想改写A这个输入,于是它就把操作改成inplace,直接对A进行,避免拷贝。这种修改的策略,在2006b后才比较正式的运用,在旧版Matlab中仍旧不同程度地存在上述问题。我在2007a中,测试了两种f的写法:
function y = f0(x) y = x + 1; end
function x = f1(x) x = x + 1; end
确实发现大量调用f1比f0快了好多倍。所以,如果确实是想改变input的话,函数定义中要让input parameter和output parameter同名,以触发这种智能的解释机制。并且建议升级到最新的2007a版本,这个版本在这方面进行了重要改善。
5月28日
自计算机诞生到现在,出现了难以计数的程序语言。排除了那些没有广泛使用的语言,我们还是有很多选择来完成我们的任务。虽然很多语言都能完成多方面的工作,但是,每种语言适合的领域还是有一些限制的。从初一开始写程序到现在,接触过好些语言了,它们主要有几个层次
- 中低级语言:ASM, C, Pascal。这些语言能够在很大程度上对硬件和内存进行操作,效率很高。很多朋友现在一直视C/Pascal的指针为编程的最爱,通过它似乎你能够控制几乎所有的事情。但是,这种语言开发周期长,而且由于限制少,危险性也相应比较高,一旦发现错误(尤其是由于指针和内存操作不小心引起的诡异问题),追踪难度很大。
- 具有面向对象特性的高级语言:C++, C#, Java。这类语言是商业应用的主流开发工具。它们具有的对象封装,多态,泛型,以及异常处理机制基本能满足很多现实需求。从C++的RTTI开始,这些语言已经逐步建立反射(Reflection)的能力。到了C#和Java,反射已经很成熟,并且在它们的新标准中,Annotation(Meta-data)也开始引入了。
- 动态脚本语言:Python/Ruby。它们使程序的运行时灵活性到达了新的高度,而且开发周期也大为缩短。我最近完成的一些事情,使我对此很有感触,一些在普通高级语言需要很多代码才能完成的事情,可以通过随手写就的精巧脚本就轻松完成了。这些语言具有了全方位的反射和动态表换的能力,甚至可以在运行时动态构建出新的类型。我们经常用的MATLAB也是属于此类,只是它的使用领域相对较窄。
目前,也常切换使用几种语言来共同完成某项工作:C++用于撰写效率关键的代码段,C#/Java用于构造带有完整GUI的软件系统,Ruby用于撰写脚本完成一些诸如批量文件处理,数据收集组织的任务。
这些语言有很多类似的地方,却有着有趣的区别:
- 泛型(Generic Programming)。通俗的说就是带参数定义的类型,大家很熟悉的例子可能就是C++里面的std::vector<T>。在一般印象中,它就是用来定义一个容器中的元素类型的。这固然是它的功能之一,但是这远不能反映泛型编程的全部能力。其实在很多系统中,它更多地用在诸如Policy, Dependency Injection等的设计模式当中,实施类型和策略之间的某种绑定。在C++, C# (ver 2.0 or above), Java (SE 5.0 or above) 都实现了泛型,但是它们三种语言就有三种本质上完全不同的实现方式。
- C++采用Template技术,在编译器依据不同的Type parameter产生多个独立的类型定义。它的好处在于,泛型相关操作都在编译期完成,因此很多情况下不需要以牺牲运行时效率为代价,效率高。但是,由于它为同一模板的所有不同参数都各自产生新的实际类型,在一些复杂系统中可能造成代码体积的急剧膨胀,从而导致编译缓慢和影响运行效率。另外,C++在生成实际类型之前不会对模板进行检测,而且生成过程带有选择性,因此使得Template的运用可以非常灵活,并由此引发了一种独特的编程方式:静态元编程(static meta-programming),就是通过模板技术的灵活运用,在编译期就实现很多重要的能力。C++开源社区里面著名的Boost和Loki库就是这方面的代表。 这种灵活性的一个代价,就是让编译错误变得难以定位。有时候,模板声明中的一些小错误由于经过多次实例生成和类型推断过程的扩散,可以衍生出数以百计的莫名其妙的编译问题。
- C# 2.0采用的是参数化的类型,就是每个泛型类型只有一处类型定义,在它的对象的类信息中带有类型参数的信息。这种技术是一种比较完善的实现,在灵活性和可控性之间达到了良好的平衡。借助C#强有力的反射机制,可以以较小的运行时代价安全地实施很多元编程的技术。
- Java采用的是Type Erasure。可以允许在源码中声明类参数,但是它们只对编译器有效。编译器通过它们进行类型安全检查,并且在bytecode里面适当地方插入类型转换指令,之后在生成的bytecode中就不再存在类参数信息了。一方面,它采用这样的技术保持了泛化容器和旧版代码中非泛型容器的兼容,但是另外一方面,却非常可惜地放弃了在运行期对类型参数进行反射的能力。也就是说,你将没法在运行期判断一个列表究竟是ArrayList<String>还是ArrayList<Person>了。另外,Java的类型参数不能是原始类型,也就是说不能做出诸如ArrayList<double>这类容器。如果非要让一个容器装double,就必须先对这些数字进行装箱(boxing),变成Double,这样就往往造成了运行时效率的大为下降了。
动态语言一般没有这种东西,因为它们不需要。它们对于类型具有强大的动态定制能力,不需要通过泛型技术进行静态定制。在某种意义上,泛型是对于普通高级语言缺乏动态类型定义能力的一种补偿。
- 反射(Reflection)。就是在运行时获取类型信息的能力。在最传统的语言中,比如C,对类型的运行时感知能力很有限,sizeof(t)算是比较重要的一个了,基本上不能做出typeof(t)之类的东西——当然,你可以考虑自己实现一个运行期类型表——但是这已经不是语言的事情了。C++到了1997年标准的时候才正式规定了RTTI(Runtime Type Identitifcation,运行期类型识别),但是所支持功能非常有限,主要是做对诸如dynamic_cast的帮助。 Microsoft的MFC里面也自己搞了一套类似的东西,主要是大量依赖预编译宏来实现的,用现在的程序设计观点来看,实在是相当拙劣。
到Java/C#,Reflection才正式作为一种重要的语言特性来实现,这得益于以Object为root的单根继承体系。它们都有一个专用类Class/Type来表达类型信息,并且提供了对类型构造进行各种检索的能力,比如查询一个类含有多少公用方法,它的父类是什么,实现了什么接口之类。
- 函数代理和回调(Delegate/Callback)。函数或者执行代码作为参数传递是实现灵活的执行控制的重要手段。从C开始,就已经可以实现。在C/C++里面,一种惯用的方法就是函数指针。但是,由于它能够和void *进行任意转换,因此并不具有类型安全的特性。而且这个指针可以在内存和代码段中任意移动,可能造成很大的安全隐患。在C++里面,还有一种新的类型安全的实施方法,就是仿函数(functor),它主要通过重载括号运算符,把class模拟成为function来使用。这种手法在STL被广泛运用。相比于函数指针,functor往往通过不带成员数据的light weight class来实现,函数执行可能在编译期便进行绑定,有时效率更高。而且与其它泛型技术比如binding等的结合十分灵活。
在C#里面,在1.0标准就在语言层次内建了delegate类型,并且提供了丰富的操作支持。到了2.0,更进一步支持泛型的delegate。而且,在delegate基础上实现了event。可以说,对回调的支持已经相当完备。
Java并不直接支持函数作为对象来传递。类似的功能通常是通过interface来间接完成的。可以通过定义单方法的接口来模拟一个代理。相对来说,这种做法比起C#显得繁琐。好在Java支持局部的匿名类(Anonymous class)定义,可以在一定程度上弥补方便性的不足。基于接口定义的函数传递同样是类型安全的。
其实,代码传递的最方便的写法是Lambda表达式,直接简洁地表达一个输入输出关系。在Ruby和Matlab都已经支持这种写法。即将推出的C# 3.0也将准备引入这个特性——这是C# 3.0令人期待的一个重要特性。而Java下一个版本SE 7.0,也可能考虑加入类似的东西,叫做closure。可见,便捷和可靠的运行期代码选择和传递将成为编程语言发展的趋势。
5月22日
最近两三个星期过得很纯粹,两耳不闻窗外事,一心只做programing。也许,实验室的同学, MSN上的朋友都觉得我怎么变得这么安静了。
本来只是一个小东西,用C#在开始的两三天就写得差不多了,后来发现里面的一些paradigm和structure可以复用,于是就开始围绕它们发展一个class library。。。。可是始终觉得一个普通的App灵活性不好,就开始开发一个类似MatLab那样的开放性Platform——My Lab,可以让用户撰写和加入新的modules,甚至按照personal的构思设计自己的语法构造。
作为建立这个platform的基本准备,写了多个子系统和相应的基本支持库:
- Data Structure Interaction:让多种数据结构比如List, Map和Set之间进行灵活交互从而实现各种数据联动行为的接口层,以各种适配器(Adapter)类为主
- Event and Observer:主要是对Observer Pattern的各种实现提供支持,以及实现多种事件传递及响应机制,和它们的组合。
- Action: 通过某些wrapper和adapter使得coding具有更接近于自然语言的描述。在它支持下,可以写下如下的东西:
Foreach.in(mylist)
.when(less_than(a).and.(greater_than(b)))
.do(Actions.print.repeat(3))
- Text Parser:提供对文本分析的支持,以及各种语法分析和搜寻器的联合架构的支持。
- Runtime Interpretation:命令和脚本运行时解释环境,提供了一个灵活的解释器所需的各种要素,基本输入输出环境(I/O),变量以及其作用域的维护,命令集,运行环境,线程控制(一个完成的Runtime Session起码包含一个消息循环的控制线程,任务运行的线程池,以及UI线程)。
为了让系统具有广泛的适应性,转入了Java的阵营。
Java可以在所有操作系统上无需重新编译直接运行。更重要的是,Java 的 Object 和 Method 可以在MatLLab下直接使用。因此上面的平台,既可以作为单独运行的平台,也可以基本不做改动就直接在MatLab下作为嵌入平台。
这个平台为以后进行要求更高的实验部署打下基础。最终希望能在各种试验条件中,都能灵活定制自己的控制脚本的语法和格式,从而能用简便的script来驱动大规模的复杂实验过程。