UML软件工程组织

发掘 C# 特性赋予科学计算项目以威力
来源:微软 原著:Fahad Gilani
 摘要 
  C#语言在多种项目中应用的相当成功,它们包括 Web、数据库、GUI及其他更多类型项目。有充分理由认为,C# 代码最前沿的应用领域之一很可能是科学计算。但 C# 能达到 FORTRAN 和 C++ 应用于科学计算项目的水平吗? 
  在本文中,通过研究由 .NET 通用语言运行时决定的 JIT 编译器、微软中间语言和垃圾收集器如何影响性能,作者回答了这个问题。他还论述了 C# 数据类型,包括数组和矩阵,及其它在科学计算 应用中起重要作用的语言特性。
  C#语言已获得工作在不同领域开发者的尊敬并在他们中间得到相当的普及。最近两年,C# 在交付健壮的产品中起着重要的作用,从桌面应用程序到 Web 服务,从高阶商务自动化到系统级应用程序,从单用户产品到网络分布环境中的企业解决方案,都有 C# 的存在。假设此语言的强大特性,你可能会问 C# 和 Microsoft®.NET 框架是否能被 更加广泛地用于除GUI和基于Web组件之外的程序中。它是否已准备好被科学团体用于开发高性能数字性编码?
  答案并不一目了然,因为首先需要回答一些其它问题。例如,什么是科学性计算(scientific computing)?为什么它不同于传统的计算?语言是否真的具备适于科学计算 编程的特性?
  本文中,我会揭示 C# 的一些内在特性,它们允许开发者以轻松、实用的方式使用注重性能(performance-critical)的代码。你将看到 C# 如何在科学 社区中扮演着重要角色,如何为下一代数字计算敞开大门。你也将看到,尽管谣传因内存管理开销过大而使受管代码运行缓慢,但是复杂性适度的代码运行得很快;它根本不会被垃圾收集器中断,因为大多数数字上的处理不会需要足够多的内存释放而导致调用垃圾收集。我将探究 C# 在数字计算世界里是不是一个好的选择。我还将考察一些基准(benchmark),并将结果与非受管 C++ 代码进行比较以便了解在性能和效率方面 C# 处在什么位置。

 计算科学
  计算机的可用性已经使科学家们更容易地证明理论、解答复杂方程式、模拟3D环境、预报天气和执行许多其它高强度运算任务。多年来,数百种高级语言被研发出来以促进计算机在这些领域的应用(其中 有些是高度专业的并发设计概念框架,例如 Ada和 Occam;有些是昙花一现的计算机科学工具,例如 Eiffel 或 Algol)。然而,有少数成为卓越的科学性编程语言,例如 C、C++ 和 FORTRAN——它们在当今科学计算领域扮演 着主要角色已有相当长的一段时间。
  但是,如何确定哪种高级语言用于科学计算呢?一般来说,某种语言要想有资格成为产生科学计算代码的平台,它必须包括的标准之一是提供一套丰富的能被用于衡量性能的工具 ,并且必须允许开发者轻松有效地表达问题域。本质上,科学计算语言应该能生成可以被细微调整的有效的高性能代码。

 性能和语言
  性能已经成为区分用于科学计算编程语言的关键因素之一。编译器和代码生成技术常常被认为是性能限制因素,但这种假定不完全正确。例如,使用最广泛的 C++ 编译器在代码生成和优化方面做得很好。 有一些微妙之处,它们与代码的效率比起来,通常就不重要了。例如,C++中应避免创建过多临时对象,尤其它是一种非常容易创建未命名临时变量但又不使用它们的语言。可以使用表达式模板达到此目的,表达式模板允许延迟数学表达式的实际运算,直到它被赋值。结果可以避免在运行时招致 巨大的抽象惩罚(abstraction penalty)。
  这不是说语言特性是影响性能的唯一因素。当进行语言之间明确的评估以衡量性能和成本时,真正评估的是编译器编写者的技巧,而不是语言本身。如果能从语言、运行时或平台中获取可接 受的性能,那么选择可能紧紧关乎个人所好。
  如果你已经体验过 C# 并且正在考虑进行真正的科学计算,那么没必要采用其它语言;C# 绰绰有余。

 MSIL和可移植性
  与所有其它面向.NET的语言一样,C# 编译成微软中间语言(MSIL),它运行于通用语言运行时(CLR)。CLR 可松散地被描述为just-in-time(JIT)优化编译器和垃圾收集器 的混合物。C# 公开和利用了CLR中的很多功能,所以更细致地研究该运行时的工作机制是很重要的。
  科学家的关键需求之一是代码可移植性。科学研究机构和实验室拥有许多平台和机器,包括基于 Unix 工作站和PC。它们常常希望在不同机器上运行代码,以追求更好的结果或因为 某一特定的机器为他们提供一套数据处理和分析工具。然而,达到完全的硬件透明度已不是一个轻松的任务而且不总是完全可能。例如,多数大规模项目开发时使用了多种语言混合 的方法;因此,很难保证在一种架构或平台上可运行的应用程序也能在另一种上运行。
  CLR 使应用程序和库可被多种语言编写,这些语言都可编译成 MSIL。然后MSIL可运行在任何支持它的架构上。现在,科学家就可用 FORTRAN 编写它们数学库,在C++中调用它们,使用 C# 和 ASP.NET 在 Internet 发布结果。
  不像 Java 虚拟机(JVM),CLR是一个常规用途环境,它被设计用来面向多种不同的编程语言。此外,CLR 提供了数据层,不仅仅是应用层的互用性并允许在语言间共享资源。
  目前,可以获得大量能输出 MSIL 的语言编译器。这些语言包括(但不限于)Ada、C、C++Caml、COBOL、Eiffel、FORTRAN、Java、LIST、Logo、Mixal、Pascal、Perl、PHP、Python、Scheme 和 Smalltalk。另外,System.Reflection.Emit 名字空间大大降低了开发 面向 CLR 的编译器的进入门槛。
  将 CLR 移植到不同架构是一项正在进行的工作。然而,一份开源实现已由 Mono/Ximian 开发出来,并且可获得 s390、SPARC 和 PowerPC 架构 以及 StrongARM 系统的实现。微软也发布了一个运行在 FreeBSD 系统上的开源版本,包括 Mac OS X。(更多信息请看 MSDN 杂志 2002 July 上 Jason Wittington 的文章 "Rotor: Shared Source CLI Provides Source Code for a FreeBSD Implementation of .NET")
  所有这些进展发生在过去的仅仅数年中。假以更多时间,很可能一个全功能的 CLR 将可以适用于所有通用架构。

 JIT 编译器是否变得更好?
  JIT 编译技术是一种非凡的技术,它为广泛的优化敞开了大门。 尽管当前实现的实际情况是:由于时间限制,能被完成的优化在数量上是限制性的,从理论上讲,它应该比现有的任何静态编译器做得要好。当然 ,这是因为注重性能的代码的动态属性或其上下文直到运行时才被充分了解或被验证。JIT 编译器能通过生成更有效代码来使用这些收集的信息,从理论上讲,这些代码每次运行都会被再次优化。通常,编译器为每个方法只发出一次机器码。一旦机器码生成,它就以原始机器速度执行。
  在科学计算编程中,这可能是一个便利的工具。科学性代码主要由数字和用数字表示的算法组成。要在合理的时间内完成这些计算,某些硬件资源需要被细心利用。虽然一些静态编译器在优化代码方面做得很好,但是 JIT 编译器的动态 本性允许使用众多技术优化资源利用,譬如基于优先级的注册配置,懒代码选择,缓存调整和特定 CPU 优化。这些技术也为更严格的优化提供了广袤的空间,譬如 强度缩量(strength reduction)和常量繁殖(constant propagation)、冗余 的存储后加载(load-after-store)、公共子表达式排除、数组边界检查排除、方法内联等等。虽然 JIT 编译器有可能实现这些类型的优化,但当前.NET JIT编译器没有 先进到这一步。
  过去,程序员要确保运行在某种机器上的代码是为底层体系架构进行过手工优化(例如软件流水线,或手工利用缓存),此乃惯例。在另一台不同硬件机器上运行同样的代码需要修改原始代码以对应新硬件。随着时间的流逝,处理器执行代码的方式 已经有所改变,它们使用专门的内置指令或技术。这些优化现在能通过 JIT 编译器发掘出来,不需要修改现有代码。结果,运行在工作站上的代码也可以在具有完全不同 体系架构的家庭 PC 上运行得一样好。
  .NET框架1.1版本发布的 JIT 编译器相对于它的前辈 1.0 版本有相当的改进。Figure 1显示 CLR1.0 和 1.1 版本的执行比较,它是通过在两个平台上运行 SciMark2.0 基准套件得出的。测试机器配置是 奔腾四 2.4GHz,256兆内存。


Figure 1 .NET 1.1中 JIT 的改进

  SciMark 基准由许多在科学计算应用中建立的通用计算要素组成,在内存访问浮点运算方面各自处理不同的行为模式。这些要素是:快速傅立叶转换(FFT)、连续 松弛迭代(SOR:Over-Relaxation iterations)、用于复杂线性系统的解决方案的蒙特-卡罗积分、稀疏矩阵乘法和稠密矩阵分解(LU) 。
  SciMark 最初用 Java 开发(http://math.nist.gov/scimark),后来被 Chris Re 和 Wener Vogels 移植到 C#(http://math.nist.gov/scimark)。注意这个实现没有使用不安全代码,这会使它运行速度提高 5 至 10 个百分点。
  Figure 1 以每秒百万浮点运算数(MFLOPS)显示了.NET框架两个版本的综合得分。这给你一个大致的概念:当前版本(1.1) 运行得如何 以及未来版本改进得将有多好。
  此图显示公共语言运行时 1.1 版本胜过 1.0 版本一大截(具体在这里是 54.1 MFLOPS )。版本 1.1 在整个实现中融进了许多性能改进技术,包括一些已加入 JIT 编译器中的针对特定架构 的优化,譬如使用 IA-32 SSE2 指令进行浮点数到整数转换。当然,编译器也对其它处理器生成对应的优化代码。
  我期待下一次发布的 JIT 编译器将表现更佳。JIT 编译器将产生比静态编译器更快的运行代码,这只是时间问题。

 自动内存管理
  从实现角度看,自动内存管理大概是 CLR 给开发者最好的礼物。与 C/C++ malloc 或 new 调用中缓慢且昂贵的链表横断式释放相比,CRL 的内存分配相对较快(堆指针仅仅被移动到下一个空闲槽)。而且,内存在运行时是自动管理的,例如自动释放和整理未使用空间。程序师不再需要追踪指针、过早释放内存块或根本不释放它们(虽然像 C# 和 Visual C++® 这样的语言仍赋予开发者那样的选择)。
  几乎可预见的是,许多开发者对考虑使用垃圾收集器的反应。然而,对于使用内存频繁的应用程序,垃圾收集器确实导致小小的运行时成本,它们还处理所有跟踪内存泄漏 的凌乱细节以及清除摇摆指针。它们始终保持对堆资源的管理、使其紧凑并可以重复利用。
  最近的研究和试验显示,在计算密集型应用中,对象的分配和释放更加频繁,垃圾收集器通过堆压塑实际上可以提高性能。另外,内存中以不同方式随机展开的被频繁引用的对象被紧凑地收集在一起以 便提供更佳的定位和缓冲利用。这大大加速了整个应用程序的性能。同时,垃圾收集器的缺点之一是其不可预测的时间性,这导致很难使收集工作只在正确的时刻执行。这个领域的研究正取得进展,垃圾收集器这些年已有改进。 届时,更好的算法会出现,从而提供更具确定性的行为。
  早先我提及,数字处理代码通常不调用垃圾收集。对一些适度简单的应用来说确实如此,在这些应用程序里,主要涉及数字,也没有太多相关的内存分配。这其实取决于问题的本质 以及你已经设计出来的方案。如果它涉及许多生命期为中短期的对象,垃圾收集器将会被相当频繁地调用。如果只有少数长生命期对象,并且一直到应用程序结束时才释放,那么这些对象将被提升 为年长一代,并导致收集调用显著减少(如果有的话)。
  Figure 2 展示了一个没有调用垃圾收集器的执行矩阵乘法的应用程序。我选择矩阵是因为它们是现实世界里许多科学 计算应用程序的核心。矩阵提供了一种实用方式来解决许多应用领域的问题,例如在计算机图形算法、计算机 X 线断层摄影、遗传学、密码学、电力网和经济学领域。
  Figure 2 代码定义了一个Matrix类,它声明一个二维数组用于存储矩阵数据。Main方法创建 该类的三个实例,每个维度都是200×200(每个对象约313KB大小)。这些矩阵每一个引用以传值方式传递到 Matmul 方法(引用本身以值方式传送而不是实际的对象),然后 Matmul 方法执行矩阵 A 和 B 的乘法,并存储结果到矩阵 C。
  为了更加有趣,Matmul 方法在一个循环中被调用 1000 次。换句话说,我控制着这些对象的重用以有效地执行 1000 次“不同”矩阵的乘法运算而没有一次调用垃圾收集器。通过.NET通用语言运行时的内存性能计数器, 你可以监视收集的次数。
  然而,对于较大型的计算,如果你请求比可用内存更多的空间,垃圾收集最终不可避免。这种情形下,你可以二者择一。例如孤立代码中性能关系密切的代码,将之改为非受管代码,然后从C#受管代码中调用它们。这里一个警告是 P/Invoke 或.NET interop 调用会招致小的运行时开销,所以你可能将其作为最后一种选择,或者如果你确信运算的粒度足以能抵消调用所需的开销,就采用它。
  垃圾收集功能不应该阻碍生成高性能科学计算代码。其目的是消除你用别的方式不得不面对的内存管理问题,只要你明白它的工作原理以及使用它所需的成本,你就不需要担心垃圾收集 的开销。
  现在让我们离开 CLR,转到语言本身。正如我早先提及,C#有许多特性使它十分适合于科学计算。下面让我们逐一讨论这些特性。

 面向对象
  C#是一种面向对象语言。既然现实世界是由具有动态属性的密切相关的对象组成,那么面向对象程序设计方法常常是科学计算编程问题的最佳解决方案。 此外,通过替代内部代码片段,结构良好,面向对象的代码可以更容易被修改以适应科学计算模型的变化。
  然而,不是所有科学计算问题都表现为类似对象的特性或关系,所以面向对象方法在处理此类问题时会导致不必要的复杂性。例如,Figure 2 中 已有的矩阵乘法代码没有使用专门的类——在单个类中声明三个多维数组的矩阵。
  问题可能涉及对象间复杂的关系,而对象的编程则带来进一步的复杂性或不必要的开销。以分子动力学(MD)为例,分子动力学广泛应用于计算化学、物理学、生物学和材料科学。最早使用计算机 进行科学计算者之一要追溯到1957年,Alder 和 Wainwright 模拟了 150 个氩原子的运动。在MD中,科学家感兴趣的是通过两个物体间的势模拟原子间的相互作用,这种 方式类似于行星、太阳、卫星和恒星之间受地心引力的影响而产生相互作用一样。采用面向对象方法建模两个原子间的相互作用可能不是一个坏想法。假设一个立方体内包含N3个原子,N为一个很大的数字。 能量守恒等式所产生的算术计算强度是如此之大,以致于借助传统的程序过程(非面向对象)来实现的话,必须进行过程简化和解决性能问题,也就是说,过程代码会 变得更复杂且性能更差。它真正取决于数据存储和所使用的算法。
  你可以用 C# 来解决问题。使用语言所具备的强大的面向对象能力可能不是最佳选择,但它为传统的科学计算程序员提供了良好的开端。单个类可以包含用于执行计算和产生结果的 所有变量和方法。尽管如此,对于相对较大的问题,由于 OOP 提供的模块化和数据完整性,它对科学计算编程来说是个很有价值的工具,这使代码扩充和重用变得 更容易。

 高精度浮点运算
  没有哪个科学计算代码能忽视精确度和准确度。即使现今最强大的计算机也只能运算有限位数精度,具备更高的精度将有助于获取更准确结果。这方面的重要性不能低估,由于计算机算术运算错误而导致的灾难会让你清楚地认识这一点,例如,1996 年著名的 Ariane 5号非载人火箭爆炸(其惯性参照系统里一个64位浮点数被错误转换为16位有符号整数……嘣)。当然,你或许不会开发火箭软件,但重要的是必须知道在许多科学计算应用程序中高精度的重要性以及为什么如此重要,同时,用于二进制浮点算法的 IEEE 754 标准 与其说有所帮助,不如说实际的约束更多些。
  C#允许浮点算术使用更高的硬件平台支持的精度,譬如 Intel 的 80 位双精度格式。这种扩展类型具有比双精度类型更高精度,它被底层硬件执行所有浮点算术时隐式使用,这样提供了准确 的或近似准确的结果。下面这段话直接摘录于 C# 规格说明书第 4.1章节,以此作为 C# 支持高精度浮点运算的例子:

  ……在形如 x*y/z 的表达式中,乘法产生超出双精度取值范围的结果,但是接着除法又使临时结果回到双精度取值范围里,表达式的事实上的结果是在更高精度范围内被求值,所产生的是有穷结果而不是无穷大。

  C#也支持十进制类型——128位数据类型,适合于金融和货币计算。

 值类型,或轻量级对象
  C#中对象类型主要有两种——引用类型(重量级对象)和值类型(轻量级对象)。
  引用类型总是在堆中分配(除非使用 stackalloc 关键字),并给予一个额外的间接层;也即,它们需要通过对其存储位置的引用来访问。既然这些类型不能直接访问, 某个引用类型的变量总是保存实际对象的引用(或 null ) 而不是对象本身。假设引用类型在堆中分配,运行时必须确保每个分配请求被正确执行。考虑下面代码,它执行一次成功的分配:

Matrix m = new Matrix(100, 100); 
  其幕后执行是:CLR内存管理器收到分配请求,它会计算存储该对象包括头部和类变量所需的内存数量。然后内存管理器检查堆中可用空闲空间,以确认是否有足够空间供这次分配。如果有,对象 所需空间会被成功分配并且对其存储地址的引用也会被返回。如果没有足够空间存储对象,垃圾收集器将被启动去释放一些空间并进行堆紧缩操作。
  如果执行成功,为了保持后续的垃圾收集操作,内存管理器将对象写入内存前还必须采取另一个重要步骤。这一步骤涉及产生一块称作写屏障(write barrier)的代码(垃圾收集器的实现细节超出本文范围)。相反地,每当有对象被写入内存或 者当对象在内存中产生对另一个对象的引用(例如原先存在对象指向新创建对象),运行时便生成写屏障。垃圾收集器功能实现的许多复杂性之一是要记住这些对象的存在 可写性,因而在收集过程中它们不会被误收集,虽然它们是被毫不相关的另一个对象所指向的对象。正如你可能会猜测,这些写屏障招致小的运行时开销,所以对于科学 计算应用来说,在运行过程中创建数百万对象不是理想场景。
  值类型被直接存储在栈中(虽然此规则有例外,我马上会讲到)。值类型不需要间接层,所以值类型变量总保存自身实际值而不能将引用保存为其它类型(因而,它们也就不能为 null)。使用值类型主要优点是它们的分配只产生很小的运行时开销。分配它们时,只是简单增加栈指针并且不需要被内存管理器管理。这些对象决不调用垃圾收集功能。此外,值类型不生成写屏障。
  C#中,简单数据类型(int,float,byte)、枚举类型和结构(struct)类型都是值类型。虽然前面我讲过值类型直接存储在栈中,但我没有使用“总是”,正如我论述引用类型时一样。包含在引用类型内的值类型不会 被存储在栈中,而是堆中,它被包含于引用类型对象中。例如,看看下面代码片段:
class Point
{
    private double x, y;

    public Point (double x, double y)
    {
        this.x = x;
        this.y = y;
    }
}      
  这个类的一个实例占用24字节,其中8字节用于对象头,剩余16字节用于两个双精度变量x和y。与此同时,引用类型是包含在值类型对象中的(例如,结构中包含数组) ,它不会导致整个对象在堆中分配。只有数组在堆中分配,对该数组的引用被置于在栈中存放的结构中。
  值类型派生于 System.ValueType,它本身又派生于 System.Object。因为这个,值类型具备像类一样的特征。值类型有构造函数(除了无参数构造函数)、索引指示器(indexer)、方法和重载运算符,它们也能实现接口。然而,它们不能被继承,也不能从其 它类型继承。这些对象容易成为 JIT 优化因素,因为它们生成有效、高性能代码。
  这里有一个警告:极其容易意外地将值类型设陷为一个对象,从而导致在堆中分配它——众所周知,这就是装箱(boxing)技术。确信你的代码 不会进行在不必要的值装箱操作,否则将失去最初得到的性能。另一个警告是:值类型数组(例如双精度或整型数组)是在堆中存放,而不是栈中。只有保存数组引用的值是存放在栈中。这是因为所有数组类型都隐含派生于System.Array,它们都是引用类型。
  对于科学计算应用来说,值类型不管怎样都比引用类型快而有效、是最优选择。下一部分,我们将考察科学计算应用中用户自定义的轻量级数据类型众多应用之一。

 复数算术
  虽然C#象C一样不支持内置的复数数据类型,但是这不能阻止你创建自己的复数类型。你可使用一个结构来实现,它具备值语义。
  复数数据为一实数有序对(称x和y)和用来相乘的虚数i,并遵循一些算术规则。下面这个例子中,z是一个复数:
z = x + yi, where i2 = -1            
  复数在科学计算应用中广泛使用,用于研究物理现象,譬如涉及障碍的电流、波长和液体流动,分析汽车减震器的桁条压力、移动,发电机和电动机的设计,建模中大矩阵的的处理。它们形成这个宇宙中几乎任何事的基本等式,所以将复数视作编程语言中 单独一种数据类型对于许多科学计算应用来说是至关重要的。
  Figure 3 展示了一个基本的用户定义复数类型的实现和它的使用。它本质上体现了C#有助于 科学计算应用的两个不同方面。它们是:结构的使用,用于创建自定义数据类型的颇有价值的资源,这种类型能被当作另一个简单类型(C#中简单数据类型都是结构),和用户定义类型的一元或二元运算符重载,这显著提高了 科学计算应用程序代码的可读性和易管理性。这一点值得进一步关注。最引人注目的是,运算符重载提高代码可读性,这在科学计算程序中极为重要。尽管使用方法调用可达到同样的效果,但这不总是一个好主意,如果代码包含许多复杂的数字表达式。以下面简单数字表达式为例,它包含三个复数:
C3 = C1 * C2 + 5 * (C1-13)      
如果此表达式以方法调用形式编写,你会得到类似下行代码:
Complex C3 = C1.multiply(C2).add((C1.minus(13)).multiply      
  忘记其中任何一处的括号,你将陷入在屏幕上查找编译错误。想象一下编写一个比上面表达式更复杂的数字表达式将会怎样。或者想象一段时间后重新回到你的代码,并打算在一个嵌套 了无数个add和multiply方法名的表达式中将“add”方法名改为“multiply”。首先你必须理解代码是做什么的,然后,如果幸运的话,经过多次不成功的修改尝试后你终于得到正确结果。运算符重载允许你以自然、合乎逻辑 的方式来表示数字表达式。
  当处理存储在栈中的实际值时,你可能认为运算符重载对执行的整理速度没有影响。理论上,它根本不应该影响性能,并且好的 C++ 编译器证明此理论是正确的。然而,由于某些限制,当前 JIT 编译器不能执行相关的优化,所以导致代码 运行比期望的要稍慢。这个问题将在 JIT 编译器未来版本中得到解决。
  虽然对于注重性能程序,当前运算符重载可能不是最优的解决方法,但是它确实提高代码可读性和重用性。实际上,科学计算社区里的开发者需要编写可理解的并且在符号与语法方面尽可能近似真实数字表达式的代码。如果你计划对自定义类型仅执行少数算术运算,而且你对使用模糊的符号不反感,那么你可以完全地忽视这些特征并获取额外的一点性能。C#让你 有选择的余地,这使它在科学计算中的应用更具吸引力。

 多维数组
  C#支持三类数组:一维数组,不规则数组(jagged array),矩形数组(rectangular array)。
  如同 C 中一样,一维数组是以0为索引起点(数组的第一个元素索引为0),它的元素在内存中依次顺序存放。因此,这类数组被隐式处理,使用的是一套专门为此而设置的 MSIL 指令(newarr,ldelem,stelem等)进行的。由于编译器能以许多方式优化它们,所以一维数组相当有吸引力力。
  当一维数形成几乎任何数字应用程序的一部分时,很少听说不用更有效的多维数组来进行数字计算的。C#中多维数组有两种:不规则数组和矩形数组。
  不规则数组是元素为一维数组的一维数组。最好把不规则数组想象成一竖列,列中每个槽指向内存中另一个存放一维数组的地址。假定一维数组在C#中MSIL层被优化, 那么感觉一维数组是最有效率的。然而,这几乎完全取决于如何访问数组。如果访问的代码地址不好,在内存里为跟踪指针而到处跳转的开销将占据执行时间。不规则数组的另一个好处(或者是问题,取决你的标准)是它的行可以是变长,因而取名为不规则。这导致每次访问新行时,要执行多个边界检查。然而,当前运行时中,不规则数组 的检索具有比矩形数组更好的优化和内联性能。
  意识到不规则数组的一些缺点,C#设计者决定在语言规格中包含类似 C 的多维数组,针对注重性能的应用程序和仅仅是更喜欢矩形数组而非不规则数组的用户。与不规则数组 不同,矩形数组被存储在连续的内存位置,而不是分散在堆中。不幸的是,矩形数组没有自己的一套 MSIL 指令和替代的用于访问数组元素的辅助性 set/get 方法。这些 辅助性方法不产生运行时开销(至少对于二维或三位数组是这样),因为JIT编译器视它们为内置的。结果,代码通过内在的许多优化技术被生成,譬如,消除边界检查和潜在使用单偏移。
  当前JIT编译器中有一个不太重要的局限,关于矩形数组边界检查的体面消除,因此,顺次(逐行)访问一个相对小的二维不规则数组可能在某些机器上比访问二维矩形数组快。对 于大数组 则不然,因为不规则数组会在获取内存中不同位置的随意数组元素时消耗很多时间。当不是线性访问每行时,你可估计到不规则数组的性能与要访问元素的数目成反比。
  由于存储地址的不合适,在对角或随机访问元素情况下,不规则数组表现相当槽糕。另一方面,矩形数组似乎是同样场景下不规则数组性能的四到五倍。Figure 4 中 的代码使用性能计数器类记录了四个不同循环,这些循环测试了顺序和对角访问大的不规则数组和大的矩形数组的情形。
  Figure 5显示了运行 Figure 4 中代码的结果。如同你所看到,顺序访问两种大数组消耗差不多的时间,反之对角访问不规则数组几乎比矩形数组慢8倍。


Figure 5 顺序和对象访问

  尽管在结构和性能方面矩形数组通常优于不规则数组,但可能有一些情况不规则数组可提供最优的解决方法。如果你的应用程序不需要排序、重排、分割、稀疏或大的数组,那么你会发现不规则数组表现得相当好。然而,注意虽然此建议适合于大多数应用程序,但它不适用于库代码。你常常没有意识到其他人使用你的库代码这种情况,这将产生性能差的系统。
  例如,你可能编写一个使用不规则数组的通用矩阵库,你将发现,它在一系列程序中表现糟糕,包括图像、图像分析、有限元分析、机械学、数据挖掘、数据拟和(data fitting)和神经网络。这是因为频繁遭遇稀疏矩阵,此矩阵中大多数元素是零或不需要的,因而需要随机访问它们。
  对于这种情形,转换到矩形数组,你的全部性能都得到显著改进。作为一个例子,Figure 6 Figure 7 测试了程序的两种不同实现的性能,此程序中将几个大矩阵相乘。每个程序使用 Figure 4中显示的性能计数器类。Figure 6 使用二维不规则数组实现矩阵,Figure 7 使用矩形数组。每个程序输出它的运行时间和得到 MFLOPS 值。这个简单的测试表明使用矩形数组的代码比使用不规则数组的等价代码快大约八至九倍。


Figure 8 不规则数组VS规则数组

  在我机器上运行两部分代码,得到的结果如 Figure 8 所示。把多维数组转化为一维数组,你甚至会得到更好的结果。如果你不介意语法,这有些琐碎;直接使用一个索引作为偏移。例如,下列代码声明一个一维数组并作为二维数组使用:
double[] myArray = new double[ROW_DIM * COLUMN_DIM];            
要索引这个数组的元素,使用下列偏移:
myArray[row * COLUMN_DIM + column];      
这勿庸置疑比等价的不规则或矩形数组快。
  如果矩形数组不作为语言的一部分,可能很难将 C# 作为一个适合科学计算的语言。目前JIT编译器的局限将有希望被克服,但是正如你看我刚显示的结果,即使有这些限制,矩形数组比不规则数组表现更好。

 不安全方法

  C#充满惊奇。作为具有C/C++背景的开发者,我不愿意放弃指针。C#支持C风格指针很令人惊讶。你可能想知道这是如何做到的,既然垃圾收集器在一次收集中移动所有对象且可能移动了你指向的对象。幸运的是,通过钉住内存中对象 ,对它进行标志,以便在收集中不移动它们,运行时允许你在垃圾收集器控制之外工作。对操纵数组而言,这是一个好消息。首先,多维矩形数组很容易线性访问,好像它们是一维数组一样。其次,当访问数组元素时,运行时执行数组边界检查以确保你没有越界访问数组(然而,有些例子,这种情况下JIT能检查访问边界并证明它不需要每次做边界检查)。在你对你的代码很自信的方面,却引入未预料的开销。
  但是有一个勾绊。使用指针的代码必须标记为不安全,且只能在完全信任环境中运行,比如,这意味着你不能直接在Internet中运行这些代码。然而,这不应该影响打算在完全信任环境中运行代码的科学程序师,除非想要编写网络程序或被普通公众使用的程序。


Figure 9 不安全代码性能

  要显示不安全代码之于安全代码执行的效率有多好,请看 Figure 9,它显示两个矩阵乘法方法的基准测试;一个使用指针访问单个数组元素,另一个没有。结果表明,不安全版本运行速度大约是安全版本的两倍。如果不安全代码使用指针运算访问数组元素,同时做乘法和加法时使用更多增量,这样的改进甚至更引人注目。

 语言互用性

  大多数开发者花时间与精力生成库或解决方案,它们都基于.NET框架之前的技术和语言。虽然将你的代码移植到C有很多优点,但当你已经 具备经过测试的代码作为基础来生成应用。这并不总是做此选择。幸运地,由于有了通用语言规范(CLS)和通用类型系统(CTS),.NET内置了对语言互用性 的支持。倘若代码是CLS兼容的,那么能有效地让其它任何语言编写的遗留代码与C#代码交互。因此,举例来说,你喜爱的用FORTRAN编写的数字库或代码,现在能无缝集成到C#程序中。你甚至可用非受管C编写代码中 注重性能的部分,然后在受管C#程序中使用它。.NET框架通常使受管和非受管代码之间的转变看起来是无缝的。
  这将给科学计算开发带来革命,因为开发者现在能同时用许多不同语言编写代码,然后轻松集成或共享代码。

 基准测试(Benchmarks)

  前面显示的基准测试结果是通过在 2.4GHz 奔腾四 256兆内存机器上运行 SciMark 2.0 基准套件获取的。为了方便比较起见,取自 SciMark 2.0 C语言版本的结果也显示在 Figure 10 中,以 便让你了解 C# 相对 C 的比较情况。


Figure 10 C#与C性能比较

  应该指出,当前C#版本的 SciMark 在其大多数内核中使用二维不规则数组,譬如 SOR、稀疏矩阵乘法和 LU。在这三个内核中,这是为什么 C# 的性能要比C差很多,而在 FFT 上与 C 近似一样的显著原因。或许切换到矩形数组会显示一个更好的图形。


Figure 11 SciMark测试结果

  这些结果不是作为两种语言性能比较的最终暗示。既然 SciMark 是直接从 Java 转变为 C#,没有使用某些非常优化的C#特性的实现通过包含可供选择的特性,如结构、矩阵数组、甚至不安全代码块, 将会得到更进一步的改善,从而与 C 有更好的对比。Figure 11 显示了所有结果的综合得分。

 结束语

  当开发者已经乐于用 C# 开发基于 Internet 组件和分布式程序时,不需要什么调查工作来证实 C# 可以作为一门科学计算语言。在本文中,我展现C#里许多强大 的特性,它们使 C# 成为开发科学计算代码的理想平台。虽然目前 C# 的性能被充分认识完全足以胜任科学计算语言,我们仍希望 JIT 编译器的下一个版本的性能会更好。

 

版权所有:UML软件工程组织