Download PDF version of this article PDF

理想的 HPC 编程语言

也许是 Fortran。或者也许这根本不重要。

Eugene Loh,Oracle

DARPA HPCS(高性能计算系统)计划旨在将跨 petaflop 系统的 HPC(高性能计算)生产力提高十倍。本文介绍了 Sun Microsystems 在参与 HPCS 计划时进行的编程能力研究。这些研究与 Sun 正在进行的新 HPC 编程语言(Fortress)的开发以及公司更广泛的 HPCS 生产力研究不同,尽管与这两项活动肯定存在重叠。

这些编程能力研究最初侧重于编程语言,但很快重心转移到其他主题。现有语言(尤其是 Fortran,它仍然可以说是 HPC 中的主要语言)被证明非常足够。编程挑战主要源于其他因素。

背景

如果编程不是指学习别人设计的语言,然后与该语言、其编译器和计算机的局限性作斗争以实现你的任务呢?如果它在某种意义上意味着相反的情况呢?你可以用对你来说最具表现力的方式编写程序,而不必考虑别人强加的语言规则。那么,定义能够理解你所写内容的编程语言、编写编译器来消化程序以及构建能够高效运行你指定的任务的计算机,将是别人的工作。

我们进行了这样的练习,以了解 HPC 应用程序“理想”的编程语言可能是什么样的。我们的方法是采用现有的 HPC 程序,让某人以适合个人的方式重写它们,不受任何现有计算机、编译器或语言的约束。相反,他被邀请写下任何看起来最具表现力的东西。我们可能无法编译或运行这些程序,但我们至少可以看到作者想要什么。

几乎立刻,我们对所看到的东西感到震惊。当然,重写的代码比原始代码更简洁易懂,但令人惊讶的是,“理想”的编程语言基本上是 Fortran。

我的首要任务是说服你,这个发现并非荒谬。我承认,这个实验是有偏见的,因为我们从现有的代码开始,这些代码大多是用 Fortran 编写的,并且使用了不仅熟悉 Fortran 而且确实拥抱它的人类受试者。然而,主要的一点与其说是每个程序员最终都会更喜欢 Fortran,不如说是原始源代码的问题更多地与现有编程语言的局限性以外的原因有关。我们在这里考察其中一些原因。

DARPA HPCS 计划还资助了新编程语言的开发:Cray 的 Chapel、Sun 的 Fortress 和 IBM 的 X10。这些语言的支持者早期就展示了在新语言中重写熟悉的 HPC 基准测试如何大幅减少源代码量——十倍的减少并不令人惊讶——但即使在 Fortran 中重写这些基准测试,也实现了类似的源代码减少和相应的表达性改进。

新的编程语言仍然有很多可以提供的,例如,在表达并发性和特别是数据分布方面。只是我们在当前 HPC 源代码中看到的臃肿主要不是源于当前语言的不足,而是源于其他因素。

我们做了什么

我们使用现代 Fortran 重写了许多 HPC 基准测试和应用程序,这种方式考虑了软件开发的人力成本:可编程性和相关特性,如可读性、可验证性和可维护性。这些都是重要的考虑因素;虽然复制粘贴是编写代码行的快速方法,但它会降低可读性并增加维护成本。

这项工作的一部分包括与 Sun HPCS 生产力小组合作,量化程序员的总体生产力,并研究人类受试者在我们的重写练习中的表现。可以使用 Hackystat 遥测工具被动地观察人类受试者的活动,或者通过访谈或让受试者记日记来主动观察。团队包括一位文化人类学家,他指导了这些观察。

在本文中,我们重点关注重写活动的输出,检查重写的 HPC 程序和源代码膨胀的原因。这里使用的特定 HPC 测试代码是 NPB(NAS 并行基准测试)CG、MG 和 BT;等离子体聚变应用程序 GTC;以及 3D 流体动力学代码 sPPM。

一个关键指标是 SLOC(源代码行数)。诚然,这是一个粗略且常常具有欺骗性的指标,但它可以作为量化源代码的可读性和表达性的便捷起点。

由于生成的计算机程序是用 Fortran 编写的,因此可以编译和运行。因此,我们能够研究它们相对于原始代码的性能,使用当前可用的工具测试自动并行化,并推测自动并行化改进的潜力。

表 1 列出了我们研究的一些 HPC 代码的原始版本和重写版本之间的 SLOC 和性能比较

表 1. HPC 代码比较

代码名称 代码行数 性能下降
之前 之后 减少
NPB CG 839 81 10 倍 1 倍
NPB MG 1,701 150 11 倍 2 倍-6 倍
NPB BT 4,234 594  7 倍 2.7 倍
GTC 6,736 1,889  3.6 倍 2.7 倍
sPPM 13,606 1,358 10 倍 2 倍


作者反馈

我们看到了源代码量的大幅减少。最小的减少是在 GTC 中,它已经使用了相对现代的 Fortran 结构,MPI(消息传递接口)并行性(分布式内存)相对较少,并且人类受试者不愿修改计算和 I/O 格式。

我们看到了各种迹象表明,重写的程序不仅代码行数更少,而且更容易阅读、验证和修改。然而,得出表达性可以大大提高这一结论的不仅仅是我们的判断。在 GTC 的案例中,我们向该应用程序的维护者之一征求了对重写程序的反馈。以下是选定的评论

乍一看,我对代码变得如此小巧紧凑印象深刻。我一直认为 GTC 已经尽可能小了,但我显然错了。我还惊喜地发现编程语言仍然是标准的 Fortran 90/95,而不是一种全新的语言。

新代码清晰、简洁且易于阅读。

所有 MPI 调用和 OpenMP 指令都被删除的事实使得代码中表示的物理更容易理解。

[重写引入了优雅的] 代码重用,体现在CHARGEPUSH.

但有这样的警告

[预计会] 性能下降,除非编译器可以执行非常好的过程间优化和/或自动内联。

这个警告的出现是因为存在许多从连续 (ζ,r,θ) 坐标到离散网格索引的转换。将这些转换封装到几个函数中大大提高了源代码的可读性和可维护性,但性能却因额外的过程调用以及许多转换的专业化和优化功能的丧失而受到影响。

单 CPU 性能

HPC 的很大一部分是性能,包括并行化。我们检查的代码显示了许多熟悉的 HPC 特性:循环展开、向量化、缓存阻塞、多线程、数据分布等等。有人可能会争辩说,虽然大幅减少代码量是可能的,但总体性能方面的代价将是无法容忍的。

我们惊喜地发现,单 CPU 性能下降总体上并没有太糟糕。事实上,对于 NPB CG,大部分工作是由低级稀疏矩阵例程完成的,总体性能实际上根本没有变化。我们预计,只要计算密集型内核(稀疏矩阵例程、密集矩阵乘法、FFT(快速傅里叶变换)等)在库或其他经过良好调整的内核中执行,就会得到类似的结果。

在其他情况下,我们看到了性能下降,但预计通过明智的、战术性的(几行)优化可以恢复大部分性能。例如,NPB MG 代码的重写通过将模版操作从数组语法转换为(可以说是更易读的)DO循环获得了 2 倍的加速。在 GTC 中,当 Fortran 的MODULO内部函数被合适的替代品取代时,一段代码的运行速度提高了四倍。当然,这样的优化会让人走上滑坡。代码膨胀会重新出现,代码的可维护性也会降低。事实上,即使是性能也可能受到影响。我们已经看到过一些案例,通过删除“优化”来简化源代码实际上提高了性能,这可能是因为“优化”起源于硬件差异足够大或目标编译器差异足够大的情况。

与此同时,交付具有表现力的 HPC 源代码的良好性能的战斗仍需继续进行。编译器优化必须辅以持续的硬件改进。在延迟隐藏技术(如预取、芯片多线程和侦察线程)方面还有很多工作要做。在某种程度上,这只是将压力从内存延迟转移到内存带宽;因此,一些系统设计人员正在解决其他问题,例如高效使用部分缓存行。

并行化

HPC 并行化通常分为两类:寻找并发性和分布数据。寻找并发性比分布数据简单得多。如果我们所说的并行化是指寻找并发性,那么我们对现有语言的谨慎乐观甚至可以扩展到并行化。但是,如果需要数据分布来实现高端性能,那么新的编程语言或结构似乎就更加重要。

HPC 很少使用锁。更典型的是,并发性与数据并行循环有关——例如,同时对所有粒子或网格元素进行时间步进。与此同时,商品计算机集群已成为 HPC 中性价比最高的选择。因此,并行化还涉及数据在集群节点上的分解。节点通常通过显式消息传递(例如使用 MPI)在 HPC 中共享数据。

考虑用于求解偏微分方程的 ADI(交替方向隐式)方法。具体来说,考虑一个 3D 矩形网格,如图 1 所示。物理上,任何网格单元上的信息都会在整个 3D 体积中传播,最终影响所有其他单元。在计算上,我们可以限制数据传播仅在计算的一个阶段沿 X 轴进行,然后在沿 Y 轴,最后沿 Z 轴。最终,计算的物理特性应保持不变。

这样的算法沿着单元格的“铅笔束”组织计算。例如,在第一阶段,X 轴对齐的铅笔束中的所有单元格都可以仅基于此铅笔束内的数据值进行更新。实际上,所有 X 轴对齐的铅笔束都可以同时更新;然后是所有 Y 轴对齐的铅笔束;最后是所有 Z 轴对齐的铅笔束。如果网格中有 N3 个元素,那么在 X、Y 或 Z 阶段中的每个阶段都有 N2 个铅笔束。也就是说,存在相当大的并发性。BT 和 sPPM 代码都是这样组织的,多维 FFT 也是如此。

伪代码可能如下所示

      INTEGER NX, NY, NZ, X, Y, Z

      DIMENSION MYDATA(NX,NY,NZ)

      FORALL ( X = 1:NX, Y = 1:NY ) CALL UPDATE(MYDATA(X,Y,:))

      FORALL ( X = 1:NX, Z = 1:NZ ) CALL UPDATE(MYDATA(X,:,Z))

      FORALL ( Y = 1:NY, Z = 1:NZ ) CALL UPDATE(MYDATA(:,Y,Z))

每个子例程调用都可以与同一FORALL语句中的所有其他调用同时进行。但是,无法将MYDATA的元素分布到多个处理器上,以便每个处理器都拥有所有计算阶段所需的数据。如果特定处理器“拥有”MYDATA(X,Y,Z),那么为了处理 X 轴对齐的数据铅笔束,它需要所有MYDATA(:,Y,Z)值。然后,为了处理 Y 轴对齐的数据铅笔束,它需要所有MYDATA(:,:,Z)值。最后,为了处理 Z 轴对齐的数据铅笔束,它需要所有MYDATA(:,:,:)值。

因此,虽然此示例中的并发性非常丰富,但分布式内存系统在显式地在处理器之间交换数据以及分布数据以最大程度地减少此类昂贵的交换方面都面临着巨大的挑战。

即使在共享内存系统中,也存在类似的问题。所有处理器都可以在原地访问所有元素,但这必须协调访问,无论是为了防止竞争条件还是为了处理缓存一致性。即使是共享内存系统也受益于空间局部性,因为处理器可以处理完整的缓存行。

如果我们专注于相对简单的并发性问题,从长远来看,我们可以帮助 HPC 程序员不必显式地进行并行化。我们将受益于软件的改进。现有编译器已经识别出一些自动并行化的机会。这包括自动作用域方面的进展——即自动分析源代码以确定变量的使用情况(私有、共享、只读共享、复制等),以便可以并行化循环。自动分析将受益于程序整体或过程间分析。

并发性的运行时管理也将有所帮助。循环可能是嵌套的,或者循环迭代可能是不平衡的。循环计数和处理器计数可能要到运行时才知道。仅静态分析无法平衡计算负载或判断细粒度并行性(为了最大并发性)和粗粒度并行性(为了分摊并行化成本)之间的平衡。

对于 HPC 程序员来说,更简单的并发性也将受益于硬件的改进。大型、全局可寻址内存有所帮助。然而,处理器在缓存数据的情况下运行速度更快,因此必须管理一致性。硬件可以通过原子操作、事务内存和主动消息来支持并发性。

虽然并发性似乎相对简单,但管理数据分布似乎是一项更加困难的任务。这是新编程语言真正可以提供帮助的一个领域。

例如,NPB BT 基准测试有一个姊妹版本 BT I/O,它向测试添加了 I/O。这提供了自适应维护的测试——即向已编写的程序添加功能。比较几乎成了一个笑话:在原始的分布式内存版本的代码中设置 I/O 添加了 144 行源代码,而重写的共享内存版本只需要额外一行!

算法复杂性

性能和并行化不是导致源代码庞大的唯一压力。另一个问题是计算科学家想要表达的想法相当低级。例如,聚变代码 GTC 模拟了洛伦兹力,物理学家可以简洁地写成

F = q(E + v x B)

但计算物理学家将其转换为许多页令人费解的方程式和相应大量的计算机代码。由于带电粒子在等离子体中以非常紧密的螺旋线运动,因此计算物理学家首先转换为“引导中心”公式。然后转换坐标以与托卡马克中的磁场对齐。这种转换引入了相当大的复杂性,但也将其代码的数值属性和性能提高了几个数量级,这种优势是仅仅购买更多计算机设备无法克服的。

一般来说,计算科学家会消除高频分量、离散网格、使用复杂的时步法、引入关键近似、展开项、转换坐标、添加耗散项和迎风差分以控制数值稳定性,并以其他方式将几个简单的方程变成代表他们试图在计算上做什么的本质的令人麻木的算法页面。放弃这种算法复杂性将使计算成本增加许多无法承受的数量级。计算科学家的面包和黄油不仅仅是数学物理方程(洛伦兹力、薛定谔方程、纳维-斯托克斯方程等),而是使在特定条件下进行计算成为可能的算法规范。Fortran 非常擅长表达计算规则。具有数组语法、泛型接口、可选参数、递归子例程的现代 Fortran,MODULEs、数组值函数和其他功能,就更是如此。如果能够像 Fortress 或 Mathematica 那样使用排版数学语法,那也很不错。

其他似乎具有无论编程语言如何都难以表达的复杂性的领域包括高级算法控制流和详细的 I/O 格式。

实施权宜之计

源代码量也会因当前软件和硬件的局限性(甚至是缺陷)而膨胀。一个例子是可移植性。HPC 程序员必须考虑不同的供应商、MPI 实现、线程模型、编译器、Fortran-C 互操作性约定、默认字长等。在其他情况下,代码会费力地重现特定的浮点数值(无论这些数值是否正确)。库可用性不一致(无论是许可还是安装和捆绑问题造成的)也是一个问题:虽然库提供了各种功能,但 HPC 代码通常有自己的随机数生成器、矩阵乘法器、稀疏矩阵支持、线性求解器和 FFT,以确保无论应用程序在哪里运行,这些功能都可用。

HPC 代码有时还实现了工具可能更好地提供的功能。示例包括性能检测、调试代码和检查点。

源代码还反映了针对瞬态错误或编译器限制的解决方法。一个例子是 Fortran 数组语法。我们发现了很多数组语法允许更高级别编程的实例。然而,许多程序员避开了优雅的语法,因为它在许多编译器中的实现还不成熟。可以说,开发新的编程语言会加剧而不是解决这样的问题。

尽管我们对现有编程语言持乐观态度,但我们承认遇到了语言改进会很好的领域。类型推断(包括数组范围的推断)将允许人们放弃繁琐的样板声明。更好地支持模版(基于附近元素更新每个元素的网格上的计算)对 HPC 非常有用。

规范、验证和确认

我们从软件开发模型开始,其中计算机程序从书面规范开始。然后,必须对其进行验证(针对规范进行检查)和确认(检查它是否在一定参数范围内实现其预期目的)。

程序有可能在编写时没有考虑到可验证性。以下是 NPB BT 代码中一个引人注目的例子

               rhs(2,i,j,k) = rhs(2,i,j,k) + dx2tx1 *

     >                 (u(2,i+1,j,k) - 2.0d0*u(2,i,j,k) +

     >                 u(2,i-1,j,k)) +

     >                 xxcon2*con43 * (up1 - 2.0d0*uijk + um1) -

     >                 tx2 * (u(2,i+1,j,k)*up1 -

     >                 u(2,i-1,j,k)*um1 +

     >                 (u(5,i+1,j,k)- square(i+1,j,k)-

     >                 u(5,i-1,j,k)+ square(i-1,j,k))*

     >                 c2)

这段代码基本上应该实现以下来自 NPB1 规范的内容

     [RHS2] = ...
         - ( ∂ / ∂ξ ) ( [u(2)]2/u(1) + φ )
         + ( ∂2 / ∂ξ2 ) ( dξ(2)u(2) + (4/3)k3k4[u(2)/u(1)] )

源代码与其应该实现的规范之间几乎没有对应关系。这与其说是编程语言的局限性,不如说是人的意图的局限性。以下是我们重写代码的方式,目的是提高可读性和可验证性

     RHS2 = RHS2 - deriv(1,1,u2**2/u1+phi)

     RHS2 = RHS2 + deriv(2,1,dx2*u2 + 4*k3*k4/3*u2/u1)

然而,更有可能的是,甚至没有规范来验证代码。当我们试图验证 GTC 并要求提供应用程序规范时,我们收到了这个有点幽默的回复:“实验室里有一位物理学家实际上逐行浏览了代码并做了一些笔记。不幸的是,这些笔记不是电子格式的,更糟糕的是……它们是中文的。”

最初可能有一个规范,但源代码随着时间的推移而演变,而规范从未更新。为了缓解规范和源代码的发散,我们着眼于使源代码(甚至是 Fortran)尽可能可读,并将源代码与规范或文档交织在一起。我们尝试了 HPCS 图分析基准测试 SSCA #2 的实现,其中“源代码”是 HTML,脚本可以从中提取 Fortran 代码进行编译和执行。这种拥有单个工件来维护的方法(而不是脱节的规范和源代码)类似于 Mathematica 笔记本、Donald Knuth 的Literate Programming和 Scientific WorkPlace 中发现的思想。

验证也很困难。必须将特定参数范围内的结果与可能从分析或前代代码中已知的结果进行比较。由于验证非常昂贵,并且严重依赖于经验丰富的科学理解和直觉,因此在 HPC 应用程序的大部分生命周期中,人们只是通过将结果与早期版本的代码进行比较来检查软件修改。虽然科学对于有限的精度(例如,1% 甚至 10%)才有意义,但在 HPC 中检查数值结果通常意味着将浮点运算结果检查到最低有效位。我们发现了一些案例,例如,我们避免更改源代码,因为更改((2*pi)*k)/N改为2*((pi*k)/N)或更改X*(1/deltat)改为X/deltat细微地改变了浮点结果。我们不知道结果是更准确还是更不准确,只知道它们略有不同。这些差异阻止我们使源代码更易读或运行更快。

程序员的优先级

项目截止日期迫使软件快速编写。然而,许多权宜的编写风格会导致程序变得更长,因此更难以阅读、理解、验证和维护。与此同时,许多编程习惯是在快速原型设计的文化中发展起来的,程序员避免使用高级语言功能,因为它们的支持还不成熟,而是专注于最后一滴性能。正如 Donn Seeley 在 文章“How Not to Write Fortran in Any Language”(2004 年 12 月/2005 年 1 月)中介绍的那样,不良编程实践的例子比比皆是。

为可验证性而编程通常不是优先事项,正如 BT 示例所说明的那样。

作为另一个例子,在 sPPM 中,我们发现了数千行用于处理边界条件的代码。重写的代码仅使用了大约十几行。造成如此惊人减少的原因有很多,但其中一个问题是,原始代码仅在需要时才尝试填充“幽灵单元”(幽灵单元是真实计算单元的副本,这种复制可以简化边界条件的处理)。在重写中,我们将例行填充所有幽灵单元。消除对是否需要此类更新的检查极大地简化了编程逻辑,在我们研究的案例中,总体性能几乎没有损失。在 HPC 中,即使在确定代码的特定部分是否对性能敏感之前,通常也会以性能而不是可编程性为目标进行编程。

关于软件维护的 ISO/IEC 标准采用了术语完善性维护。然而,当其他目标(例如修复缺陷、实现新功能、调整性能和迁移到新平台)争夺注意力时,仅仅修改源代码以提高其可维护性通常很少受到关注。

NPB BT 源代码需要数百行代码来计算时间导数 dU/dt 以形成“右侧”。此计算似乎从头开始实现了两次,一次在文件rhs.f中,另一次在exact_rhs.f中。即使最初忽略了这种重复工作,完善性维护也应该消除这种冗余,以使自首次编写以来不得不查看此源代码的 HPC 工作人员受益——当然,前提是这对软件的所有者很重要。

我们接下来该怎么做?

在更大的 HPC 程序上重复这些编程能力研究会很有趣。特别是,最好从足够小以至于一个人可以编写的自包含程序(DeRemor 和 Kron 将其称为“小型编程”)转向由多人编写的更大的软件,以及许多部分之间的接口很重要(“大型编程”)。像自然界一样,源代码在不同的尺度上看起来不同:从快速原型设计到自包含应用程序,再到数十年的遗留代码。进一步研究将源代码特征与人类生产力指标进行经验关联也将很有趣。

最重要的是,HPC 社区可以从社区范围内的努力中受益,以强调可编程性和人类生产力。没有哪个部分是第一位的。所有方面都需要取得进展:语言开发、编译器成熟度、硬件创新、HPC 软件开发实践,甚至采购和竞争性基准测试。

但是,当我们从现有语言开始时,我们受益于可用的编译器、系统、参考代码、经验和程序员。
Q

喜欢它,讨厌它?请告诉我们

[email protected]

EUGENE LOH ([email protected]) 是 Oracle 公司的首席软件工程师,并且作为 Sun HPCS 活动的一部分参与了可编程性、性能和生产力研究。他目前的重点是基于 MPI 的 HPC 应用程序的性能。

版权所有 © 2010,ORACLE 和/或其关联公司。保留所有权利。

acmqueue

最初发表于 Queue 第 8 卷,第 6 期——
数字图书馆 中评论本文





更多相关文章

Matt Godbolt - C++ 编译器中的优化
在向编译器提供更多信息方面需要权衡:这可能会使编译速度变慢。链接时间优化等技术可以为您提供两全其美的优势。编译器中的优化仍在不断改进,即将到来的间接调用和虚函数分派方面的改进可能很快就会带来更快的多态性。


Ulan Degenbaev、Michael Lippautz、Hannes Payer - 作为合资企业的垃圾回收
跨组件跟踪是解决跨组件边界的引用循环问题的一种方法。只要组件可以形成具有跨 API 边界的非平凡所有权的任意对象图,就会出现此问题。CCT 的增量版本在 V8 和 Blink 中实现,从而能够以安全的方式有效且高效地回收内存。


David Chisnall - C 不是一种低级语言
在最近的 Meltdown 和 Spectre 漏洞之后,值得花一些时间来审视根本原因。这两个漏洞都涉及处理器推测性地执行超出某种访问检查的指令,并允许攻击者通过侧信道观察结果。导致这些漏洞以及其他几个漏洞的功能被添加进来,是为了让 C 程序员继续相信他们正在用低级语言编程,而这种情况已经几十年没有发生了。


Tobias Lauinger、Abdelberi Chaabane、Christo Wilson - 你不应该依赖我
大多数网站使用 JavaScript 库,其中许多库已知存在漏洞。理解问题的范围,以及库被包含的许多意想不到的方式,仅仅是朝着改善现状迈出的第一步。本文的目标是,其中包含的信息将有助于为社区提供更好的工具、开发实践和教育工作。





© 版权所有。

© . All rights reserved.