SISAL:用于数值计算的安全高效语言
早在计算机技术发展的初期,著名的计算机科学家约翰·巴科斯发明了一种名为 Fortran 的编程语言。鉴于他的其他成就,大多数计算机科学家可能已经原谅了他这一点。毕竟,他怎么会知道他的发明会发展成一个弗兰肯斯坦怪物,扫除所有用更令人愉快和有用的工具取代它的尝试呢?
后来,巴科斯对函数式语言产生了兴趣。解释函数式语言的最佳方式是向您展示一种非函数式语言。让我们看看以下 C 代码
int increment_me(int deposit){ static int balance = 0; balance = balance + deposit; if (balance < 0) { fprintf(stderr,"Balance negative!\n"); } return(balance); }
此 C 函数通过向您的帐户添加存款和扣除取款(负存款)来跟踪您的银行余额。如果您透支帐户,它还会发出警告。您的余额最初假定为零。后续调用会在此余额上增加或扣除。此外,如果您达到负余额,系统会发出警告。
Increment_me 在 C 语言的意义上是一个函数,但在数学意义上不是函数。数学函数,如正弦或对数,每次您询问时都会给出特定问题的相同答案。例如,90 度的正弦值始终为 1。相比之下,increment_me 在内部存储您当前的余额,这意味着从调用它返回的结果取决于先前调用的结果。
通过将 increment_me 重写如下,很容易将其转换为真正的数学函数
int increment_me(int deposit, int balance){ balance = balance + deposit; if (balance < 0) { fprintf(stderr,"Balance negative!\n"); } return(balance); }
您,而不是 increment_me,必须在此版本中跟踪您的银行余额,但至少此 C 函数现在也是一个数学函数——它始终给出特定问题的相同答案!
此例程的函数式结构的优势是什么?假设 increment_me 是复杂会计系统的一部分,用于跟踪许多不同的余额。在这种情况下,在 C 函数内部存储单个余额无法完成工作,因为该函数仅适用于该特定帐户。此外,如果会计系统的其他方面需要访问您的余额,则在原始版本的 increment_me 中无法做到这一点,因为余额隐藏在函数中。
新版本 increment_me 的缺点是什么?假设 increment_me 在 C 函数调用堆栈中向下十层。那么,通过此堆栈向下传输您感兴趣的余额将非常麻烦。人们可能会试图使用 C 语言的全局变量功能来代替
global int balance;int increment_me(int deposit) { balance = balance + deposit; if (balance < 0) { fprintf(stderr,"Balance negative!\n"); } return(balance); }
这样,系统中任何位置的任何人都可以将特定帐户的余额插入全局变量 balance 中,并启动最终导致调用 increment_me 并增加余额的调用级联。
这种做事方式有什么问题?非局部性。如果出现问题,您会将其归咎于我们不起眼的函数中的错误,还是错误发生在系统中的其他位置?如果您曾经处理过这种程序,您就会知道调试起来可能非常困难。
函数式程序通过遵循以下三条规则来避免这些问题
函数不记得有关先前调用的任何信息。
函数不更改其调用参数。
函数不访问全局变量。
此类函数被称为没有副作用。函数式语言是强制执行这些限制的语言。部分函数式语言的示例包括历史悠久的 Lisp 语言及其衍生语言,如 Scheme,以及 ML 语言。Haskell 和原始版本的 Lisp 是纯函数式的。
并行计算机的出现给计算世界带来了一系列全新的问题,主要问题是如何划分任务,以便多个处理器可以同时处理它。为普通语言设计自动并行化编译器的尝试喜忧参半。目标是定义子任务,以便最大限度地减少它们之间的数据流,因为通常并行计算机中各个处理器之间的通信带宽是有限的。这在普通语言中很困难,因为全局变量和副作用的存在使数据流变得非常复杂——见我们关于 increment_me 的第三个示例。
由劳伦斯利弗莫尔国家实验室的 James McGraw 领导的计算机科学家团队介入了。1985 年,他们发明了一种名为 SISAL 的函数式语言,专门用于并行化科学数值计算。该语言的函数式特性允许编译器跟踪程序中的数据流,因此可以就如何在并行计算机中的处理器之间分配工作做出明智的决策。
在过程级别上的函数式行为(如我们的过程,用于增加或减少某人的银行余额)对于他们的目的仍然不够。为了获得足够精细的数据流信息,他们需要使每个程序语句在数学意义上都是函数式的。为了理解此要求的含义,请注意常用的程序语句形式
a = a + 1
在这种语言中将是非法的,因为在数学中,变量如 a 不能同时是显式函数定义中的输入和输出。必须改为写成
b = a + 1.这意味着变量只能被赋值一次,并且只能赋值一次。这就是该语言被称为 SISAL 的原因,即 Single Assignment Language 中的 Streams and Iteration(单赋值语言中的流和迭代)。关于“单赋值”的部分指的是该语言的上述特征。显而易见的挑战是如何在这种语言中进行迭代。“流”指的是一种抽象,旨在用于该语言的输入输出过程,但这从未开发出来。
SISAL 的单赋值性质使其使用方式与常用的语言(如 C 和 Fortran)非常不同。它也使 SISAL 的学习曲线陡峭但很短,尤其是对于那些习惯于传统编程技术的人来说。
关键是停止将计算机视为一组可由一系列指令随意更改的存储单元。相反,将 SISAL 视为数学定义的嵌套序列。C 程序员熟悉一个例子。C 语言中正常的 if 语句可能像这样运行
if (i > 0) { a = i; } else { a = -i; }
我们将其识别为将 i 的绝对值赋值给 a。但是,C 语言 if 语句的另一种形式是赋值的 if,它可以完成相同的操作
a = if i > 0 ? i : -i;这更接近于相应的 SISAL 语句的精神
a := if (i > 0) then i else -i end if;回到 C 语言 if 的两种类型,请注意第一种形式可能会出现错误,尽管它在语法上是正确的,但可能不是您想要做的;只需删除 else {a = -i;} 子句!这仍然是合法的 C,但当 i <= 0 时,它会使变量 a 未定义。这种错误在 SISAL 或第二种类型的 C 语言 if 中根本不可能发生,因为对于语句来说,所有可能性都必须被考虑在内才能在语法上正确。
C 语言中赋值的 if 在功能上非常有限。例如,如果需要完成多个赋值,则需要创建多个赋值的 if,并且逻辑测试会为每个赋值重复。在 SISAL 中,只需执行以下操作
a, b, c := if (i > 0) then expression for a, expression for b, expression for c else alternate expression for a, alternate expression for b, alternate expression for c end if;
或者,如果计算结果需要的不只是一个简单的表达式,则可以使用如下结构
a := if (i > 0) then let x = ...; y = ...; in if (x > y) then x else y end if end let else ... end if;let...in...end let 结构允许我们用任意复杂的计算替换简单的表达式。
我们如何在迭代中绕过单赋值规定?让我们先看看问题是什么。C 语言中计算数组 a 累积和的循环可能采用以下形式
b[0] = a[0];i = 1; while (i < n) { b[i] = b[i - 1] + a[i - 1]; i = i + 1; }
请注意我们现在被禁止的朋友 i = i + 1 的存在。C 语言和类似语言中的所有循环都有一些像这样的结构,无论是隐式的还是显式的。但是,正如我们所指出的,这样的结构在 SISAL 中是非法的。此外,数组 b 被赋值 n 次,这也违反了 SISAL 的单赋值性质。在 SISAL 中,我们执行以下操作
b := for initial i := 0; bvalue := a[0]; while (i < n - 1) repeat i := old i + 1; bvalue := old bvalue + a[i]; returns array of bvalue end for;for initial 子句执行一次,设置 i 和 bvalue 的初始定义。然后循环开始。每次循环命中 while 语句时,变量 i 重命名为 old i,bvalue 重命名为 old bvalue,使名称 i 和 bvalue 未赋值并准备好重复使用。最后,在循环结束后,returns 子句收集 bvalue 的值,包括来自 for initial 子句的值,并将它们作为数组返回。虽然这种循环方式对于单赋值语言来说实际上有点障眼法,但它再次满足了保持数据依赖性显式的目标。old 子句的存在清楚地表明,循环中产生的值取决于循环先前迭代中生成的值。
如果稍后迭代中的计算不依赖于先前迭代的结果,则可以使用替代循环结构。例如,要将数组 a 的每三个元素乘以 -1,SISAL 执行以下操作
newa := for i in 0:n - 1 a1 := if (mod(i,3) = 0) then -a[i] else a[i] end if; returns array of a1 end for;
如果一个循环可以以这种形式表示,那么并行化就变得微不足道了,因为循环的每个分支都可以由不同的处理器执行,而处理器之间没有相互通信。
完整的 SISAL 函数采用以下形式
function fun_name(argument_list returns return_list) expression, expression, ... end function
参数列表指定所有参数的名称和类型。例如,包含两个名为 i 和 j 的整数以及一个名为 a 的实数数组的参数列表将被指定为 i, j: integer; a: array[real];。由两个实数数组组成的返回列表将如下所示:array[real], array[real]。表达式可以是简单或复杂的,涉及 let、for、if 或其他语句。函数体中的表达式数量必须与返回值的数量匹配。
除了基本类型 boolean、character、integer、real 和 double_real 之外,还有复合类型 array、stream(类似于数组,但元素按顺序访问)、record(类似于 C 结构)和 union。每个复合类型在稍有限制的情况下,都可以由简单类型或复合类型组成。例如,SISAL 中的二维数组只是数组的数组。
所有变量都是动态分配的,变量类型是从上下文中确定的。用户定义的类型使用如下语句声明
type oner = array[real];type complex = record[u: real_part; v: imag_part]; type complex_array = array[complex];
事实证明,SISAL 是一种非常安全的语言。与 C 相比,我总是发现,当代码首次成功编译时,我在使用 SISAL 的工作程序方面走得更远。SISAL 具有许多传统的安全功能,例如严格的类型检查、外部函数的原型设计和数组边界检查。数组随身携带有关数组长度和数组索引起始值的信息。多维数组实际上是数组的数组,因此边界检查既可以单独用于每个索引,也可以用于整个数组。由于边界检查会降低程序执行速度,因此可以在调试完成后将其关闭。
SISAL 从其函数式和单赋值性质中获得了更多的安全性。正如我在上面展示的,SISAL 中不可能出现 if 语句中未定义的备选项。我发现这具有迫使人们在编写程序时非常彻底地思考算法的效果。因此,编写 SISAL 程序通常比编写等效 C 程序的初稿花费更多时间,但额外的努力在调试阶段和更好地理解计算方面得到了百倍的回报。
说到调试,传统的调试器(如 gdb)不能很好地与 SISAL 配合使用,因此该语言的开发人员提供了自己的调试器 sdbx。此外,“peek”函数允许在程序执行期间检查变量的值。
在许多方面,由于其函数式和单赋值性质,SISAL 比传统语言更容易调试。但是,某些行为需要一些时间才能适应。例如,在函数中
function polar(x, y: real; returns real) let r := sqrt(x*x + y*y); theta := atan(y/x); in r end let end function
变量 theta 的所有痕迹都会消失。由于此变量的计算对最终答案 r 没有贡献,因此生成 theta 的语句是死代码,并被 SISAL 优化器删除。这产生了 SISAL 中的一条铁律:如果计算对最终结果没有贡献,则会毫不留情地消除它。如果上面的函数中确实需要 theta 的值,则应通过将函数定义更改为 ...returns real, real) 并将 in 子句中的 r 更改为 r, theta 将其包含在输出中。否则,应从代码中删除 theta 的计算。
如果 SISAL 仅此而已,那么它将是一种优雅但无用的语言。由于每次赋值都会创建新变量,因此任何重要的 SISAL 程序都将是一个巨大的内存泄漏。当涉及到数组时,这一点尤其重要。在 SISAL 中,每次更改数组元素时,显然都会创建一个全新的数组!
我说显然,是因为 LLNL 的 SISAL 编译器 osc 的后端非常擅长优化掉单赋值语义的糟糕效率。事实上,这是 SISAL 开发团队的主要贡献。osc 首先将 SISAL 代码转换为名为“IF1”的中间形式。然后对该中间代码进行广泛的按摩,然后再转换为 C 或 Fortran 代码。因此,SISAL 实际上是一个花哨的 C(或 Fortran)预处理器,这意味着它在非并行化形式下非常容易移植到具有良好 C 编译器的架构,例如 gcc。几乎任何 UNIX 或 Linux 系统都将在单处理器模式下编译 SISAL 代码。osc 的开发人员声称,优化的 SISAL 代码通常在直接用 C 或 Fortran 编码的相同算法所需时间的 20% 以内执行,而我对该语言的经验支持这一说法。有趣的是,LLNL 的 John Feo 用 SISAL 编写的快速傅里叶变换例程在 Cray 计算机上的并行模式下实际运行得更快,即使它在单处理器模式下稍慢,也比 Cray 自己的并行快速傅里叶变换例程更快。
创建并行 SISAL 代码有点困难。SISAL 需要 C 或 Fortran 编译器以及实现并行性的底层操作系统才能生成并行代码。鉴于并行系统类型的多样性以及这些系统的各种自定义接口,将 SISAL 移植到新的并行系统并非易事。因此,即使 Linux 现在具有可用的对称多处理,我也尚未尝试使 SISAL 使用它。
科学家和工程师经常编写利用有用代码库的计算机程序。这些库几乎总是用 Fortran 编写的。此类库的重要性通常被认为是不要放弃 Fortran 的一个原因。
SISAL 通过提供 Fortran 和 C 的接口来顺应这一现实。连接是双向的;SISAL 可以调用 C 和 Fortran 例程,C 和 Fortran 可以调用 SISAL 例程。Fortran 接口比 C 接口更发达,这对像我这样讨厌 Fortran 的人来说有点沮丧,但好消息是 C 可以轻松地用于模拟 Fortran 代码,从而允许 C 程序利用 Fortran 接口。
在函数式语言中进行输入和输出的自然方法是通过顶级函数的参数列表将输入漏斗化,并通过函数返回值将输出漏斗化。SISAL 开发人员采用了这种保守的方法,设计了一个名为 FIBRE 的特殊输入输出系统。FIBRE 以特殊的 ASCII 形式表示原始类型、数组、记录等。这种形式还包含有关数据的信息,例如数组大小和记录内容,以及数据本身。
虽然具有一定的优雅性,但如果您试图解密全球天气预报模型的输出,这种格式就严重不足。
正是在这里,SISAL 与 C 和 Fortran 的接口可以发挥作用。此接口可用于执行用户所需的任何类型的输入和输出。但是,这种解决方案有点不优雅,因为它要求用户深入研究跨语言接口的肮脏细节。对于普通科学家或工程师来说,这可能是一次令人畏惧的经历,并且它违背了提供安全、高效且易于使用的计算工具的目的。
如果可以在 SISAL 和广泛使用的标准数据格式之间编写灵活的接口,则可以解决此问题。我已经编写了这样一个到 NetCDF 库的接口。NetCDF 是一种用于存储和检索网格化数值数据的格式和应用程序编程接口。它由大气研究大学联盟的 UNIDATA 计划开发,在气象学和海洋学领域中的普及程度迅速提高,并且开始蔓延到其他领域。目前存在许多工具可以操作和显示这种格式的数据,并且越来越多的工具不断涌现。NetCDF 库是开源软件,可从 UNIDATA 的网站获得。但是,如果您幸运的话,您的 Linux 发行版已经有可用于安装的 NetCDF 预打包版本——例如,它在我使用的 Debian GNU/Linux 发行版中可用。
清单 1 显示了一个完整的 SISAL 应用程序(长杆中热传输方程的解)。NetCDF 用于写出结果。使用 Dan Kelley 的 GRI 图形包显示了沿杆的温度模式随时间的演变。(有关 GRI 的文章,请参见 2000 年 7 月号的Linux Journal。)

图 1. 示例 SISAL 程序的输出
大约在 1990 年,LLNL 的 SISAL 人员认为 SISAL 已经准备好在利弗莫尔的范围之外进行烟雾测试,并为人们提供在利弗莫尔 Cray 上测试 SISAL 的时间。我接受了他们的提议,并很快被这门语言所吸引。当时我正在使用 Sun 工作站,但最终开始转换为 PC 上的 Linux。SISAL 当时无法在 Linux 上编译,但问题证明相对微不足道,因此在 LLNL 的 Pat Miller 的帮助下,我们将 SISAL 移植到了 Linux。
作为 SISAL 的真正烟雾测试,我编写了一个热带天气研究模型,我仍然在使用它,目前它由 4,600 行 SISAL 代码组成。我认为 osc 编译器相对健壮,尽管毫无疑问仍然存在我尚未发现的错误。
我的模型的当前版本使用 C 接口与我的 Candis(C 语言分析和显示)包进行通信,与外部世界对话。开发此接口有助于我轻松实现 NetCDF 到 SISAL 的接口。
不幸的是,SISAL 项目于 1997 年被取消。但是,在 James McGraw 的帮助下,osc 编译器源代码被置于开源版权之下。作为为数不多的 SISAL 爱好者之一,我现在维护着 SISAL 的网站。
SISAL 项目代表了近期一件相对罕见的事情,即计算机科学家们集中才智,专注于对物理科学家和工程师具有真正重要意义的问题。其结果是一种用于高性能数值计算的非常有趣的计算机语言,以及使其对目标客户真正有用的必要工具。
尽管 SISAL 项目最终未能在 LLNL 产生重大影响,但其转移到开源世界为它提供了另一次机会。现在需要的是愿意尝试它的人,并确定它是否可以帮助他们比当前的替代方案更有效地完成工作。还需要愿意解决问题并在已完成工作的基础上进行构建的知识渊博的人。一个显而易见的扩展是使 SISAL 使用 Linux SMP 进行并行计算。一个更雄心勃勃的目标是将其扩展到并行计算的分布式内存模型,或许使其能够利用消息传递接口 (MPI),MPI 通常用于大规模并行计算机(如 Linux Beowulf 系统)上。
