Zig 编程语言以性能和安全性为核心设计目标,近年来逐渐受到欢迎。由于 Zig 基于 LLVM 构建,因此能够利用该生态系统的诸多优势,包括访问丰富的支持后端,这使得 Zig 在高性能工作负载方向具备显著潜力。然而,Zig 尚未在高性能计算(HPC)领域赢得广泛关注,其中一个原因是其缺乏基于预编译指令(pragma)实现共享内存并行计算的能力。
译|zouyee
为了帮助读者深入了解Kubernetes在各种应用场景下所面临的挑战和解决方案,以及如何进行性能优化。我们推出了<<Kubernetes经典案例30篇>>,欢迎订阅。
Zig 编程语言以性能和安全性为核心设计目标,近年来逐渐受到欢迎。由于 Zig 基于 LLVM 构建,因此能够利用该生态系统的诸多优势,包括访问丰富的支持后端,这使得 Zig 在高性能工作负载方向具备显著潜力。然而,Zig 尚未在高性能计算(HPC)领域赢得广泛关注,其中一个原因是其缺乏基于预编译指令(pragma)实现共享内存并行计算的能力。
本文描述了如何通过优化 Zig 编译器来支持 OpenMP 循环指令,并使用 NASA 的并行基准测试套件(NPB)来测试其性能表现。 Zig 与 OpenMP 的集成不仅在扩展性上可与 Fortran 和 C 的 NPB 参考实现相媲美,同时在某些场景下,Zig 的性能相较Fortran来说,提升幅度多大1.25倍。
Index Terms: Zig, OpenMP, LLVM, High Performance Computing, NAS Parallel Benchmark suite
目前Zig 社区在推动移除LLVM、LCD以及Clang代码库依赖,详见https://github.com/ziglang/zig/issues/16270
介绍
随着高性能计算(HPC)领域迈入Exascale时代,面临的一个关键问题是如何选择用于超级计算机日益复杂场景的编程语言。目前 Fortran 和 C 等传统语言仍然占据着HPC领域的绝大多数份额。
Zig 是一种系统编程语言,由 Andrew Kelly 于 2016 年创建,设计目标是追求快速和安全。近年来,围绕这一语言逐渐形成充满活力的生态。目前Zig 在 HPC 领域已有一些边缘应用,例如 Cerebras 的 CSL 编程技术(用于其 Wafer Scale Engine,WSE)的开发是基于 Zig ,但总体而言,Zig 在 HPC 中尚未被广泛采用。其未被广泛采用的原因之一是语言本身缺乏对常见 HPC 编程技术的支持。尽管 Zig 的 C 互操作性特性十分出色,使得使用 MPI 相对简单,但其缺少对于 HPC 中普遍存在的基于编译指令(pragma)的共享内存并行编程的支持,因此需要对Zig编译器进行修改。
本文探索了一种通过为Zig 编译器添加OpenMP 循环指令的支持,实现基于编译指令的共享内存并行特性。通过调用 LLVM 的 OpenMP 运行时库,我们描述了支持 OpenMP 循环指令所需的修改,并比较了 NASA 的 NAS 并行基准测试套件(NPB)中内核在 C、Fortran 和 Zig 之间的性能表现。本文的结构如下:第二部分描述了该研究的背景;第三部分探讨了为支持 OpenMP 循环指令对 Zig 的增强;第四部分重点介绍了评估方法,包括将 Zig 与 C 和 Fortran 集成的探索;第五部分分析了 Zig 与 OpenMP 在这些基准中的性能表现;最后,第六部分总结了本文的研究,并讨论了未来工作。
本文的主要贡献包括:
1. 描述如何与 Zig 编译器中集成 OpenMP 循环指令。
2. 首次探索 Zig 与 Fortran 代码的集成方式,为将 Zig 应用于更大规模的传统代码库提供了可能。
3. 对 Zig、Fortran 和 C 在三项 HPC 基准测试中的进行性能对比,其中涵盖线程化时的加速比以及运行时性能。最终证明 Zig 表现良好,是 HPC 领域的可行选择
背景与相关工作
LLVM 是一套工具和库,部分用于生成和操作LLVM-IR[1]。LLVM 上构建了丰富的后端,可以从这种内部表示生成机器代码,并支持多种硬件,包括 CPU、GPU 和 FPGA。除了在主代码库中提供针对 C 和 Fortran 的前端工具(如 Clang 和 Flang)外,LLVM 还被许多流行的编程语言使用,如 Swift[2]、Rust[3]和 Zig[4]。
LLVM 还提供了其自身的 OpenMP 库。OpenMP 是一种通过多线程实现的基于编译指令的共享内存并行编程技术,也是 HPC 中最受欢迎的编程技术之一。OpenMP 的核心功能通过指令实现,因此编译器需要修改以支持这些编译指令。OpenMP 标准[5]规定了 C、C++ 和 Fortran 程序员如何使用该技术,其中编译器指令在 C 和 C++ 中表示为预编译指令(pragma),而在 Fortran 中则表示为特殊注释。定义指令开始的这串标记被称为标志符。
Zig 是由 Andrew Kelly 于 2016 年创建的系统编程语言,旨在成为 C 的一种更优化、更安全且更易读的替代方案[4]。Zig 的设计专注于减少程序执行时间,同时在安全性和编程体验上相比 C 提供更高的水平。Zig 使用 LLVM 编译器基础设施[6]进行代码生成,从而能够利用其优化功能[7]。这种对 LLVM 的利用使得 Zig 支持大量 CPU 架构和操作系统,其目标是支持所有由 LLVM 支持的目标平台[8]。
Zig 提供了一些安全特性来改善软件开发体验,主要包括比 C 更强的类型系统和改进的静态分析功能,以及在调试模式下编译器启用的可选运行时安全检查。静态分析功能可以帮助程序员防止常见的错误,例如解引用空指针或与整数和浮点数类型转换相关的截断和舍入错误。例如,在 C 中,对于int *ptr = 0
,解引用并读取ptr
是合法的,但在运行时可能导致段错误。示例1 中的两个代码示例展示了 Zig 中如何防止这一问题。这两个示例均无法编译。第一个示例尝试将整数文字赋值给指针,这种隐式转换被 Zig 的类型系统所禁止。示例 1 中的第二个示例使用内置的@intToPtr
函数执行显式的整数到指针转换,这种也会失败,因为在 Zig 中,只有可空指针可以被赋值为零。
var ptr: *i32 = 0;
_ = ptr.*;
// —————————————
var ptr: *i32 = @intToPtr(*i32, 0);
_ = ptr.*;
示例 1:在 Zig 中如何表示解引用和读取
ptr
的示例(基于 C 中的int *ptr = 0
)。然而,由于 Zig 提供的安全性,这些示例均无法编译
在编译时无法识别的错误可能会通过运行时的安全检查标记为未定义行为。Zig 为代码编译提供了两种模式:生产模式和调试模式。在调试模式下,额外的代码会被插入到可执行文件中,例如检查是否发生了数组越界或整数溢出。如果发生此类情况,会触发运行时错误。而在生产模式中,出于性能原因,不提供此类安全检查,因此未定义行为不会被捕获到。Zig建议程序员在开发代码时使用调试模式,在代码成熟后切换到生产模式。
Zig 的设计目标之一是与现有的 C 代码库实现互操作[9]。这使得开发者能够利用 C 的库和框架,例如 MPI[10],并在项目中逐步用 Zig 替代 C。为实现这种互操作,Zig 提供了一种方法,既可以调用 C 函数,也可以让 C 调用 Zig 函数。示例2展示了从 Zig 调用 C 标准库函数puts
的示例。
extern fn puts(s: [*:0]const u8) c_int;
pub fn main() void {
// calling puts
_ = puts("hello world");
}
示例 2:从 Zig 调用 C 函数的示例
示例3 展示了函数add
被导出以供 C 使用的示例。该函数可以通过编译为目标文件(object file)或静态库(so)方式,链接到 C 程序中进行访问。此外,Zig 可以自动生成一个包含所有导出函数签名的 C 头文件,供 C 程序调用。
pub export fn add(a: c_int, b: c_int) c_int{
return a + b;
}
示例3:导出 Zig 函数供 C 使用的示例
Zig 编译器还提供了将 C 源代码转换为 Zig 的工具,这可以加速将整个项目或部分项目迁移到 Zig 的过程。此机制也被编译器用于自动解析 C 头文件,并导入其中的函数、结构体和常量。示例4展示了一个来自示例2的修改代码,其中函数puts
的显式声明被替换为通过自动翻译 C 标准库stdio.h
头文件所实现的导入。
// Importing the C stdio.h header
const stdio = @cImport(@cInclude("stdio.h"));
pub fn main() void {
// calling the puts function from the stdio.h header
_ = stdio.puts("hello world");
}
示例4:通过 Zig 导入 C 头文件并调用其中函数的示例
Zig适配OpenMP
LLVM 提供了OpenMP 运行时库,而本文工作的目标是调用该库提供的函数,在 Zig 中实现基于 pragma 的共享内存并发编程。本节探讨了将带有 OpenMP pragma 注解的 Zig 源代码与 LLVM 的运行时库连接的方法。所有修改均基于 Zig 0.10.1版本。
如第 II 节所述,OpenMP 依赖于 pragma 来指定程序如何并行,但 Zig 本身并不支持 pragma语法。因此,必须将其作为一种新类型的语句添加到Zig中。我们决定将 pragma 实现为特殊的注释,这与 Fortran 中的支持方式类似。
Zig 编译管道的第一步是词法分析(tokenisation),主要决策点是选择将整个 pragma 解析为单个标记,还是将其每个字段解析为独立的标记,分别对应图 1 中的选项 A 和 B。最终决定通过标识符(sentinel),然后将 pragma 的其余部分作为常规代码进行标记化,假装标识符不存在。这种方法之所以可行,是因为 pragma 完全由 Zig 本身使用的标记组成。
图 1:解析方式选择的示意图,A) 将整个 pragma 解析为单个标记,或 B) 将 pragma 分解为多个标记
Zig 的词法分析器支持对关键字进行标记化。因此,最初计划利用此机制来解析 OpenMP 的指令和子句(例如parallel
或default
)作为关键字。然而,这种方法行不通,因为在 Zig 中关键字不能用作标识符,添加这些关键字会破坏与现有代码的兼容性。因此,解决方案是将 OpenMP 的关键字存储为标识符,并在解析时将其与常规标识符区分开。
标记化完成后,下一步是解析,这一步从标记生成抽象语法树(AST)。Pragma 应像其他语句一样被处理,Zig 解析器的核心是eatToken
方法。该方法接受一个枚举值,表示标记的类型(称为标记标签)。如果下一个标记与标签匹配,则返回并推进解析器到下一个标记,否则返回 null。然而,由于 OpenMP 关键字未分配唯一的标签,该函数无法按正常工作。因此,添加了一组新标签来表示不同的 OpenMP 关键字,并使用字符串到关键字标记的哈希映射来识别字符串是否为关键字。我们修改了eatToken
函数,使其能够接受新增关键词,并在解析 OpenMP 关键字标签时相应地解析标识符标签。
每个 OpenMP 指令都有一个 AST 节点标签,子句作为节点数据存储。子句数据存储在extra_data
数组中,该数组是Zig 编译器用于注释 AST 节点的杂项数据的 32 位整数数组。所有子句数据必须能够以这种形式表示,其中所有子句都存储在单个数据结构中,其整数表示子句不同。
private
、firstprivate
和shared
子句被定义为标识符的列表。在获取每个标识符的 AST 节点索引后,这些索引被连续存储在extra_data
数组中,子句结构的开始和结束索引则存储在子句中。图 2 展示了private
子句的一个示例,其中指令节点包含一个索引到extra_data
数组,表示子句结构的起始位置。
图 2:将私有变量存储在
extra_data
数组中的示例
非列表子句的存储大小是静态已知的,因此可以将它们存储在单一结构中。通过将该结构标记为压缩结构,可以将其视为一个 32 位整数并存储在extra_data
数组中。这种方法允许通过读取数组的单个索引提取所有数据,无需进一步的间接访问。例如,循环调度信息存储为一个 3 位的枚举值(表示调度类型)以及一个 29 位的整数(表示区块大小),其支持多达 536,870,912 次迭代。由于区块大小必须大于 0[5],值 0 表示未指定区块大小。
有些子句可以用少于 32 位表示,并将它们组合到一个压缩结构中。例如,default
子句使用 2 位的枚举表示,而nowait
子句用一个布尔值表示,占用压缩结构中的 1 位。collapse
子句则占用 4 位,因为用户通常不会希望折叠超过 16 层的循环。
在将 OpenMP 的 pragma 进行词法分析和语法解析后,下一步是代码生成。支持 OpenMP 的典型编译器会在指令的位置插入对 OpenMP 运行时的调用。这里的替换需要在编译期改造AST语法树时完成。
我们最初尝试直接修改 AST 并注入所需的 OpenMP 调用。然而,在 Zig 中,AST 节点与原始源代码之间存在严格的关联性,因此无法随意添加新的节点。基于此,我们尝试了一种变通方案:在解析目标源代码之前,向其开头预置一个函数和结构定义模板,以便在代码生成期间复制这些模板来完成 OpenMP 函数和结构实例化。然而,由于当前 Zig编译器的设计,此方法不可行,因为在编译过程中很难找到这些模板的位置并将其传播到 AST中。
因此,我们采用了基于预处理器的方法,这种方法的优点在于可以轻松生成新代码,而无需手动确保每个标记和 AST 节点引用源文件都在固定位置。当然,这种预处理方法也存在一些挑战,主要是因为 Zig并未涵盖该场景的步骤。首先,所有未使用的函数参数和非全局范围的变量必须显式丢弃,这意味着只有已知会使用的变量才应生成。第二个挑战是,在预处理阶段缺乏语义上下文(例如变量类型及其用途),这一点在III-B3中有更详细讨论。
将预处理器纳入 Zig 编译器的一个核心部分,具有以下几个优点。首先,这使预处理器可以复用 Zig 编译器中内置的解析基础设施。其次,通过在文件加载后立即执行预处理器,可以在无需修改的情况下继续使用编译器的缓存机制。
我们的预处理器在多个环节运行,通过每次处理不同的 OpenMP 构造来替换相关代码。其总体算法的伪代码在清单 5中进行了描述。例如,所有并行区域在工作共享循环之前被替换。因此,只要嵌套的构造属于不同类型,就无需在预处理器中进行特殊处理。伪代码中的<<adjust source offset>>
是因为节点以源代码列表的偏移量表示,因此在每次替换代码后必须调整修改的位置偏移量。此外,伪代码展示了为每个替换节点通过create-payload
创建一个负载(payload)。此负载包含进行替换所需的信息,例如每个指令需要在源代码中执行替换的位置,以及该指令的具体信息。
FUNCTION preprocess (source, step)
ast := parse-source-into-ast(source)
replacements := empty-list
FOREACH node IN ast DO
IF node IS OpenMP-node AND
<<node matches current step>> THEN
append(replacements, create-payload(node))
END
END
IF step = parallel THEN
FOREACH replacement IN replacements
<<perform parallel region replacement>>
<<adjust source offset>>
END
ELSE IF step == while THEN
FOREACH replacement IN replacements
<<perform worksharing loop replacement>>
<<adjust source offset>>
END
END
IF <<is last step>> THEN
RETURN source
ELSE
RETURN preprocess(source, step)
END
示例5:替换 OpenMP Pragma 和子句的预处理器算法的伪代码
大多数编译器通过函数分解的方式表示 OpenMP 并行区域,其中生成一个包含并行区域内容的函数[11]。访问这些区域的变量,例如默认共享的变量或通过shared
、firstprivate
或reduction
子句显式捕获的变量,会作为参数传递给该函数。然后,该函数的指针被传递给 OpenMP 运行时库的函数,该函数会在每个线程上调用它。例如,LLVM 的 OpenMP API 使用__kmpc_fork_call
实现此功能。
我们选择采用上述方法,传递给分解函数的变量作为参数传递给 OpenMP 运行时库函数__kmpc_fork_call
,后者将它们转发给分解函数的回调。__kmpc_fork_call
是变参函数,接受可变数量的参数。我们的设计是将参数分为三组,每组表示为?*anyopaque
指针,这是 Zig 中等同于 C 的void *
的类型。这三组参数指向包含firstprivate
、shared
和reduction
子句变量的结构体。
在分解函数中,一旦?*anyopaque
指针被还原为其原始类型,就会为这些结构体的每个成员变量创建变量并初始化值。例如:
firstprivate
子句,值为并行区域外作用域中的变量值;shared
子句,需要通过指针访问变量,并将共享变量的访问重写为指针访问;private
变量,只需在分解函数中简单定义。Reduction 操作更为复杂,通过使用 Zig 的标准原子类型创建一个值来实现。一个 reduction 结构体被创建,包含指向这些原子值的指针,并以与其他变量相同的方式传递给分解函数回调。分解函数为每个 reduction 变量创建一个单独的变量,并使用 reduction 变量中持有的初始值进行初始化。初始化必须符合 OpenMP 标准[5]。为了保证线程安全,基于原子类型定义了原子读-修改-写操作。
然而,这种方法受限于 Zig 支持的原子操作。目前 Zig 仅支持加法、减法、最小值、最大值、二进制与(AND)、或(OR)、非与(NAND)、异或(XOR)和比较交换(CAS)。例如,乘法和逻辑与或不支持。我们使用 CAS 循环算法[12]实现了这些缺失的 reduction 操作。
atom := <<value to be updated>>
operand := <<value to update it with>>
old := atomic-load(atom)
new := old * operand
WHILE TRUE DO
exchange-success, actual-value :=
compare-and-swap(&atom, old, new)
IF exchange-success THEN
BREAK
ELSE
old = actual-value
new = old * operand
END
END
清单 6 展示了我们使用 CAS 算法实现乘法 reduction 的伪代码。
与并行区域不同,工作共享循环不需要分解函数。Clang 的 OpenMP API 提供了两种实现工作共享循环的策略:
__kmpc_for_static_*
函数实现;__kmpc_dispatch_*
函数实现。这两种策略都要求明确循环的上界、下界、增量和比较操作符:
while
循环的条件中获取;静态调度的__kmpc_for_static_*
包括:
__kmpc_for_static_init
:执行循环迭代;__kmpc_for_static_fini
:每个线程完成后调用以最终化循环。对于动态循环,__kmpc_dispatch_next
用于处理下一个批次的迭代,而__kmpc_dispatch_init
接收调度类型(如kmp_sch_dynamic_chunked
、kmp_sch_guided_chunked
、kmp_sch_runtime
)。
预处理器尽量利用已有的变量名和表达式,例如,在分解函数中解包private
和firstprivate
变量时复用相同的变量名。但也有不尽如人意之处,例如,shared
变量必须重写为指针访问,而工作共享循环的 reduction 临时变量可能不能与其对应的shared
变量同名。
由于预处理时缺乏语义上下文,这种替换更具挑战性。得益于 Zig 的简单语法[13]和无变量遮蔽机制,其只需利用 AST即可实现变量重写。
OpenMP 标准定义了一组运行时函数,这些函数必须由符合 OpenMP 实现的运行时库提供。这些函数旨在让用户直接调用,通过omp_
前缀标识,例如omp_get_thread_num
和omp_get_num_threads
。为了使 Zig 程序员能够使用这些函数,我们在标准库中添加了一个omp
命名空间,并通过 Zig 编译器的translate-c
功能将所有函数声明从 C 转换为 Zig。这些转换后的函数声明随后被重新导出,同时移除了omp_
前缀。示例 7 展示了如何通过此方法在 Zig 中获取线程 ID:
const omp = @import("std").omp;
const thread_id = omp.get_thread_num();
示例 7:使用 OpenMP 库封装器在 Zig 中获取线程 ID。
除了为 Zig 程序员提供的标准 OpenMP API,我们还需要一个内部 OpenMP API 供预处理器使用,以实现将 OpenMP 编译指令映射到 LLVM OpenMP 运行时库。这些函数和常量并非由 OpenMP 标准定义,而是由运行时实现(例如 LLVM OpenMP 的libomp
)提供。这些函数声明与标准 OpenMP 函数采用相同方式转换,但被放置在.omp.internal
命名空间中。与标准 API 不同,这些函数在导出时没有移除前缀,它们非直接供程序员使用。
此外,.omp.internal
命名空间还包含一些开发的辅助工具,这些工具被预处理器用来支持 OpenMP 功能的实现。例如,__kmpc_dispatch_*
和__kmpc_for_static_*
函数族的通用封装器,以及 III-B1 节中描述的 CAS 循环 reduction 算法。
实现方法
本文中的所有基准测试均在 Cray-EX ARCHER2 超级计算机的单节点上进行。每个节点由两个 64 核 AMD EPYC 7742 处理器组成,每个核包含32KB 的 L1 数据缓存、32KB 的 L1 指令缓存和 512KB 的 L2 缓存,以及由四个核共享的 16.4MB 的 L3 缓存。基准测试中使用的 OpenMP 运行时为基于 LLVM 13.0.0 的 libomp,它被 AMD 优化的 C 和 Fortran 编译器(AOCC)使用。C 和 Fortran 基准测试的参考实现分别通过 AOCC 的 Clang 和 Flang 编译器进行编译,使用相同的 OpenMP 运行时。与 Zig 版本基准测试集成的 Fortran 代码由 GNU gfortran 编译器(版本 7.5.0)编译,所有 C 代码均由 AMD Clang(版本 13.0.0)编译。AOCC 和 gfortran 的版本均为基准测试时 ARCHER2 平台上可用的最新版本。
每项基准测试针对每种线程数运行 5 次,并报告 5 次运行的平均值。执行时间使用参考实现中的内部计时器测量。
由于目前尚无现成的 Zig 高性能计算 (HPC) 基准测试,本文决定使用其他成熟 HPC 语言(如 Fortran 和 C)创建的基准测试,并将其转换为 Zig。为将 C 代码转换为 Zig,本文使用了 Zig 提供的translate-c
子命令。然而,该工具实际使用中存在一些限制。首先,它会忽略所有 C 的编译指令(pragma),导致所有 OpenMP 相关信息丢失。其次,translate-c
会在转换代码为 Zig 之前执行 C 的预处理器,这意味着所有通过#include
引入的头文件也会被转换并出现在生成的 Zig 代码中。例如,示例 8 展示了一段定义返回预处理器定义常量的 C 代码,而清单 9 显示了转换后的 Zig 代码,尽管CONSTANT
被定义了,但并未直接使用,取而代之的是其展开后的值。因此,尽管该工具有一定帮助,但这些限制意味着部分代码仍需手动移植并进行严格验证。
#define CONSTANT (37 + 5)
int foo(void) { return CONSTANT; }
示例 8: 用 translate-c 子命令转换的 C 程序
pub export fn foo() c_int {
return @as(c_int, 37) + @as(c_int, 5);
}
pub const CONSTANT = @as(c_int, 37) + @as(c_int, 5);
示例 9: 使用 translate-c 子命令从 C 代码转换的 Zig 程序
Zig 编译器没有提供 Fortran 等效的 translate-c,因此所有 Fortran 代码都需要手动移植。然而,Zig 和 Fortran 之间有几个主要区别,最显著的是 Fortran 中的数组是从 1 开始索引的,且 DO 循环的上界是包含在内的,而 Zig 中则不是。因此,在这样的移植过程中,所有数组索引和循环下界都需要调整,这增加了复杂性。
尽管以前从未这样做过,但从 Zig 调用 Fortran 过程的过程类似于调用 C 函数,所有参数类型都更改为指针。此外,为了符合 LLVM 的名称修饰方案,必须在函数名的末尾添加一个下划线。同样,也可以从 Fortran 调用 Zig 函数,但必须再次注意名称修饰方案。例如,只有 GNU gfortran 提供可预测的全局变量条目名称。
结论
在这项工作中,我们利用了 NAS 并行基准测试 (NPB) 套件中的内核,该套件包括基于计算流体动力学 (CFD) 应用中常见算法模式的各种内核。
共轭梯度 (CG) 是我们选择的第一个基准测试,它利用了我们支持的大量 OpenMP 特性。我们将 Fortran 中的 conj_grad 子程序移植到 Zig 中,该子程序占据了大约 95% 的运行时间。这个子程序包括并行和工作共享指令、private、shared 和 firstprivate 变量共享子句、nowait 子句,以及在并行区域和工作共享循环上的归约操作。此外,这个内核还代表了在高性能计算 (HPC) 中形成大量工作负载的迭代算法。
图 3:CG 基准测试(C 类)在不同线程数下的加速比(包括我们在 Zig 中的方法和 Fortran 参考实现)
图3展示了在不同线程数下进行强缩放时,在C类问题规模下CG 内核的加速比。两种语言在 64 个线程以内通常遵循阿姆达尔定律,但在 96 和 128 个线程上运行时表现显著优于预期。这似乎是算法的固有特性,因为我们的 Zig 移植版和参考实现都遵循几乎相同的加速曲线,表明在这个基准测试中,Fortran 中的 OpenMP 和 Zig 中的 OpenMP 之间的性能非常相似。
表 I:在强缩放时,Zig 和 Fortran NPB CG 基准测试(C 类)在不同线程数下的运行时间
Number of threads | Zig runtime (s) | Fortran runtime (s) |
---|---|---|
1 | 149.40 | 170.17 |
2 | 82.34 | 83.35 |
16 | 21.85 | 21.80 |
32 | 11.26 | 11.28 |
64 | 5.83 | 5.98 |
96 | 2.80 | 2.98 |
128 | 1.81 | 2.07 |
表I显示了 Zig 和 Fortran 中 CG 基准测试的运行时间,可以看出,Zig 版本在单核上比 Fortran 代码快 1.15 倍,随后在所有其他线程数下性能大致相等,尽管 Zig 往往比 Fortran 略快。
极易并行 (EP) 内核仅关注计算性能,不需要线程之间的同步,并具有高效的内存访问模式。除了计时和验证例程外,我们将整个代码从 Fortran 移植到 Zig。这个内核利用了 private 和 firstprivate 变量共享子句,以及并行区域归约。此外,还使用了 threadprivate 和 atomic 指令。
图 4:EP 基准测试(C 类)在不同线程数下的加速比(包括我们在 Zig 中的方法和 Fortran 参考实现)
图4显示C类问题规模时进行强缩放时,Zig 移植版和 Fortran 参考实现版本的 EP 基准测试的加速比。可以看出,对于 Zig 移植版和参考实现,因为该算法不需要线程之间的通信,所以加速比与线程数成正比。例外情况出现在 128 个线程时,Fortran 参考实现的加速比超过了 128 倍,意味着该基准测试受益于超线性缩放,而在 Zig 移植版中未观察到这种情况。这可能是由于 Fortran 版本在更多线程数下更好地利用了缓存,因为每个线程的问题规模减少了。
表 II:在强缩放时,Zig 和 Fortran NPB EP 基准测试(C 类)在不同线程数下的运行时间
Number of threads | Zig runtime (s) | Fortran runtime (s) |
---|---|---|
1 | 147.66 | 185.26 |
2 | 76.17 | 94.90 |
16 | 9.84 | 11.83 |
32 | 4.72 | 5.92 |
64 | 2.29 | 2.84 |
96 | 1.57 | 1.97 |
128 | 1.36 | 1.42 |
表II显示了在强缩放时,Zig 移植版和 Fortran版本实现的 EP 基准测试的运行时间,可以看出,Zig 版本平均比Fortan版本快 1.2 倍。这与 CG 基准测试类似,基于 Fortran 在这些科学工作负载中的流行程度,我们对此结论感到惊讶。尽管 Fortran 版本在 128 核时表现变好,但其执行速度仍然比 Zig 版本的基准测试慢。
整数排序 (IS) 内核包含间接内存访问,旨在对内存子系统施加压力。该内核利用了 private 和 firstprivate 共享指令,并使用了 static,1 调度。IS 基准测试与本文考虑的其他基准测试的主要区别在于,它是用 C 语言编写的,我们将占总运行时间约 70% 的 rank 函数移植到了 Zig。
图 5:IS 基准测试(C 类)在不同线程数下的加速比(包括我们在 Zig 中的方法和 C 参考实现)
图5显示了在C类问题规模下进行强缩放时,Zig 移植版与 C 参考实现的 IS 基准测试在不同线程数下的加速比。可以看出,这两个版本的基准测试在整个线程数范围内遵循非常相似的缩放模式,然而 Zig 版本在初始阶段缩放得更好,因此在较多线程数时能够提供更大的加速比。
表 III:在强缩放时,Zig 和 Fortran NPB IS 基准测试(C 类)在不同线程数下的运行时间
Number of threads | Zig runtime (s) | Fortran runtime (s) |
---|---|---|
1 | 11.87 | 9.29 |
2 | 6.12 | 4.76 |
16 | 1.05 | 0.93 |
32 | 0.55 | 0.54 |
64 | 0.33 | 0.31 |
96 | 0.29 | 0.28 |
64 | 0.27 | 0.24 |
表III显示了在强缩放时,IS 基准测试的运行时间。可以看出,与 Fortran 基准测试相比,对于用 C 实现的基准测试,C 版本在单线程上表现最佳。虽然在并发1场景下运行时差异明显,在更多线程数时,两种语言的性能非常接近。
后续工作
在本文中,我们探讨了通过添加 OpenMP 的循环共享结构来增强 Zig。Zig 最初设计为一种系统编程语言,并利用了 LLVM 生态系统,该语言的一个主要特点是提供性能和安全性,这使其成为高性能计算(HPC)未来的一个非常有趣的潜在编程语言。虽然在 Zig 中调用 C 函数的能力意味着与 MPI 的集成相对简单,但支持基于 pragma 的 OpenMP 方法需要对编译器进行额外工作,但这对于该语言被 HPC 社区采纳却至关重要。
在描述了我们通过在编译器中支持 OpenMP 循环指令来为 Zig 添加基于 pragma 的共享内存并行性的方法之后,我们进行了使用 NASA 的 NPB 基准测试套件的性能对比。我们证明了该方法提供了类似于 C 和 Fortran 编译器的线程缩放,通过观察发现 Zig 基准测试的运行时间低于其 Fortran 对应版本。鉴于 Fortran 在科学计算中的应用范围,这些结果令我们感到惊讶。
我们认为推动 Zig 在 HPC 中落地的关键性条件将是为 Zig 编译器添加支持分析功能。目前,Zig 编译器使用 Tracy 库[17]进行分析,该库的 Zig 接口是编译器本身的一部分,不能在应用程序中使用。修改编译器以自动为应用程序添加调用该库的代码,提供类似于 gprof 的功能。此外,增强 Zig 与 Fortran 之间的互操作性非常重要,这将使 Zig 能够集成到现有的大型 Fortran 代码库中。虽然在本文中我们已经证明了这种集成是可行的,但这需要在编译器内部进行额外的工作,并可能扩展 Fortran 标准,以确保这种方法的可靠性和一致性。
总之,我们得出结论,Zig 编程语言所提供的性能和安全性组合使其有潜力应用于 HPC 工作负载场景。通过增强编译器以支持 OpenMP 循环指令,我们提供了在 Zig 中基于 pragma 的共享内存并行性的能力,并证明了其缩放性与其他语言相当。此外,对于 HPC 工作负载,性能甚至超过原有语言。
由于笔者时间、视野、认知有限,本文难免出现错误、疏漏等问题,期待各位读者朋友、业界专家指正交流。
参考文献
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有