NVIDIA HPC 编译器用户指南
前言
本指南是描述如何使用 NVIDIA HPC Fortran、C++ 和 C 编译器的一组手册的一部分。这些编译器包括 NVFORTRAN、NVC++ 和 NVC 编译器。它们与目标系统上的汇编器、链接器、库和头文件协同工作,并包括用于 GPU 计算的 CUDA 工具链、库和头文件。您可以使用 NVIDIA HPC 编译器为 NVIDIA GPU 以及 x86-64 和 Arm Server 多核 CPU 开发、优化和并行化应用程序。
NVIDIA HPC 编译器用户指南 提供 NVIDIA HPC 编译器命令行开发环境的操作说明。NVIDIA HPC 编译器参考手册 包含有关 NVIDIA 编译器对 Fortran、C++ 和 C 语言标准的解释、语言扩展的实现以及命令行编译的详细信息。用户应具有 Fortran、C++ 和 C 编程语言的先前经验或知识。这些指南不教授 Fortran、C++ 或 C 编程语言。
读者对象
本手册适用于使用 NVIDIA HPC 编译器的科学家和工程师。要使用这些编译器,您应该了解高级语言(如 Fortran、C++ 和 C)以及并行编程模型(如 CUDA、OpenACC 和 OpenMP)在软件开发过程中的作用,并且您应该对编程有一定的了解。NVIDIA HPC 编译器可在各种 NVIDIA GPU 以及基于 x86-64 和 Arm CPU 的平台和操作系统上使用。您需要熟悉系统上可用的基本命令。
兼容性和标准符合性
您的系统需要运行正确安装和配置的 NVIDIA HPC 编译器版本。有关安装 NVIDIA HPC 编译器的信息,请参阅软件随附的发行说明和安装指南。
有关更多信息,请参阅以下内容
美国国家标准编程语言 FORTRAN,ANSI X3. -1978 (1978)。
ISO/IEC 1539-1 : 1991,信息技术 – 编程语言 – Fortran,日内瓦,1991 (Fortran 90)。
ISO/IEC 1539-1 : 1997,信息技术 – 编程语言 – Fortran,日内瓦,1997 (Fortran 95)。
ISO/IEC 1539-1 : 2004,信息技术 – 编程语言 – Fortran,日内瓦,2004 (Fortran 2003)。
ISO/IEC 1539-1 : 2010,信息技术 – 编程语言 – Fortran,日内瓦,2010 (Fortran 2008)。
ISO/IEC 1539-1 : 2018,信息技术 – 编程语言 – Fortran,日内瓦,2018 (Fortran 2018)。
Fortran 95 手册完整 ISO/ANSI 参考,Adams 等人,麻省理工学院出版社,剑桥,马萨诸塞州,1997 年。
Fortran 2003 手册,Adams 等人,Springer,2009 年。
OpenACC 应用程序编程接口,版本 2.7,2018 年 11 月,http://www.openacc.org。
OpenMP 应用程序编程接口,版本 5.0,2018 年 11 月,http://www.openmp.org。
VAX Fortran 编程,版本 4.0,数字设备公司(1984 年 9 月)。
IBM VS Fortran,IBM 公司,修订版 GC26-4119。
军用标准,Fortran,DOD 美国国家标准编程语言 Fortran 补充,ANSI x.3-1978,MIL-STD-1753(1978 年 11 月 9 日)。
美国国家标准编程语言 C,ANSI X3.159-1989。
ISO/IEC 9899:1990,信息技术 – 编程语言 – C,日内瓦,1990 (C90)。
ISO/IEC 9899:1999,信息技术 – 编程语言 – C,日内瓦,1999 (C99)。
ISO/IEC 9899:2011,信息技术 – 编程语言 – C,日内瓦,2011 (C11)。
ISO/IEC 14882:2011,信息技术 – 编程语言 – C++,日内瓦,2011 (C++11)。
ISO/IEC 14882:2014,信息技术 – 编程语言 – C++,日内瓦,2014 (C++14)。
ISO/IEC 14882:2017,信息技术 – 编程语言 – C++,日内瓦,2017 (C++17)。
组织结构
本指南包含有关如何使用 NVIDIA HPC 编译器的基本信息,并分为以下几节
入门指南 介绍了 NVIDIA HPC 编译器,并描述了它们的使用和总体功能。
使用命令行选项 概述了命令行选项以及与任务相关的选项列表。
多核 CPU 优化 描述了多核 CPU 优化和相关的编译器选项。
使用函数内联 描述了如何使用函数内联,并展示了如何创建内联库。
使用 OpenMP 描述了如何使用 OpenMP 进行多核 CPU 编程。
使用 OpenACC 描述了如何使用 NVIDIA GPU,并介绍了如何使用 OpenACC。
使用 Stdpar 描述了如何使用 C++/Fortran 标准语言并行性来编程 NVIDIA GPU 或多核 CPU。
PCAST 描述了如何使用 HPC 编译器的并行编译器辅助测试功能。
使用 MPI 描述了如何将 MPI 与 NVIDIA HPC 编译器一起使用。
创建和使用库 讨论了 NVIDIA HPC 编译器支持库、共享对象文件以及影响编译器行为的环境变量。
环境变量 描述了影响 NVIDIA HPC 编译器行为的环境变量。
分发文件 – 部署 描述了在成功构建、调试和编译文件后如何部署文件。
跨语言调用 提供了示例,展示了如何在 Fortran 程序中放置 C 语言调用,以及如何在 C 程序中放置 Fortran 语言调用。
64 位环境的编程注意事项 讨论了程序员在以 64 位处理器为目标时应注意的问题。
C++ 和 C 内联汇编和内部函数 描述了如何在 C++ 和 C 程序中使用内联汇编代码,以及如何使用直接映射到汇编机器指令的内部函数。
硬件和软件约束
本指南描述了针对 NVIDIA GPU 以及 x86-64 和 Arm CPU 的 NVIDIA HPC 编译器的版本。有关特定于环境的值和默认值以及特定于系统的功能或限制的详细信息,请参阅 NVIDIA HPC 编译器随附的发行说明。
约定
本指南使用以下约定
- 斜体
用于强调。
等宽
用于文件名、目录、参数、选项、示例以及文本中的语言语句,包括汇编语言语句。
- 粗体
用于命令。
- [ item1 ]
通常,方括号表示可选项目。在本例中,item1 是可选的。在 p/t-set 的上下文中,方括号是指定 p/t-set 所必需的。
- { item2 | item 3 }
花括号表示需要选择。在本例中,您必须选择 item2 或 item3。
- filename …
省略号表示重复。可以出现零个或多个前面的项目。在本例中,允许多个文件名。
FORTRAN
Fortran 语言语句在本指南的文本中使用缩小的固定点大小显示。
C++ 和 C
C++ 和 C 语言语句在本指南的测试中使用缩小的固定点大小显示。
术语
本指南通篇使用许多与系统、处理器、编译器和工具相关的术语。例如
加速器 |
FMA |
-mcmodel=medium |
共享库 |
AVX |
主机 |
-mcmodel=small |
SIMD |
CUDA |
超线程 (HT) |
MPI |
SSE |
设备 |
大型数组 |
MPICH |
静态链接 |
驱动程序 |
linux86-64 |
NUMA |
x86-64 |
DWARF |
LLVM |
OpenPOWER |
Arm |
动态库 |
多核 |
ppc64le |
Aarch64 |
下表列出了 NVIDIA HPC 编译器及其相应的命令
编译器或工具 |
语言或功能 |
命令 |
---|---|---|
NVFORTRAN |
ISO/ANSI Fortran 2003 |
nvfortran |
NVC++ |
ISO/ANSI C++17,具有 GNU 兼容性 |
nvc++ |
NVC |
ISO/ANSI C11 |
nvc |
通常,名称 NVFORTRAN 用于指代 NVIDIA Fortran 编译器,而 nvfortran 用于指代调用编译器的命令。NVIDIA HPC 编译器的每个编译器都使用类似的约定。
为简单起见,编译器的命令行调用示例通常引用 nvfortran
命令,并且大多数源代码示例都以 Fortran 编写。NVC++ 和 NVC 的使用与 NVFORTRAN 一致,尽管这些编译器有一些不适用于 NVFORTRAN 的命令行选项和功能,反之亦然。
目前正在使用各种各样的 x86-64 CPU。这些 CPU 大多是向前兼容的,但不是向后兼容的,这意味着为特定处理器编译的代码不一定在前一代处理器上正确执行。
发行说明中提供了 NVIDIA HPC 编译器支持的处理器选项表。该表还包括编译器使用的功能,这些功能使其在兼容性方面脱颖而出。
在本手册中,约定使用“x86-64”来指定 x86 兼容、启用 64 位并运行 64 位操作系统的 CPU 组。x86-64 处理器在对各种预取、SSE 和 AVX 指令的支持方面可能有所不同。如果此类区别对于给定的编译器选项或功能很重要,则在本手册中会明确指出。
相关出版物
以下文档包含与 NVIDIA HPC 编译器相关的其他信息。
AT&T UNIX 系统实验室公司 System V 应用程序二进制接口处理器补充 (Prentice Hall, Inc.)。
System V 应用程序二进制接口 X86-64 架构处理器补充.
Fortran 95 手册完整 ISO/ANSI 参考,Adams 等人,麻省理工学院出版社,剑桥,马萨诸塞州,1997 年。
VAX Fortran 编程,版本 4.0,数字设备公司(1984 年 9 月)。
IBM VS Fortran,IBM 公司,修订版 GC26-4119。
Kernighan 和 Ritchie C 编程语言 (Prentice Hall)。
Samuel P. Harbison 和 Guy L. Steele Jr. C: 参考手册 (Prentice Hall, 1987)。
Margaret Ellis 和 Bjarne Stroustrup,AT&T Bell 实验室公司 注释 C++ 参考手册 (Addison-Wesley Publishing Co., 1990)。
1. 入门指南
本节介绍如何使用 NVIDIA HPC 编译器。
1.1. 概述
用于调用编译器(例如 nvfortran 命令)的命令称为编译器驱动程序。编译器驱动程序控制编译的以下阶段:预处理、编译、汇编和链接。一旦文件被编译并生成可执行文件,您就可以在系统上执行、调试或分析程序。
通常,使用 NVIDIA HPC 编译器涉及三个步骤
在包含 .f 扩展名或其他适当扩展名的文件中生成程序源代码,如 输入文件 中所述。此程序可能是您编写的程序,也可能是您正在修改的程序。
使用适当的编译器命令编译程序。
在系统上执行、调试或分析可执行文件。
您可能还想部署您的应用程序,但这并非必需步骤。
NVIDIA HPC 编译器允许对这些常规程序开发步骤进行多种变体。这些变体包括以下内容
在预处理、编译或汇编后停止编译,以保存和检查中间结果。
为驱动程序提供控制编译器优化或指定各种功能或限制的选项。
将中间文件(如预处理器输出、编译器输出或汇编器输出)作为输入包含在内。
1.2. 创建示例
让我们看一个使用 NVIDIA Fortran 编译器创建、编译和执行程序的简单示例,该程序打印
hello
创建您的程序。对于此示例,假设您在文件
hello.f
中输入以下简单 Fortran 程序print *, "hello" end
编译程序。当您创建程序时,您将其命名为
hello.f
。在本例中,我们使用默认的nvfortran
驱动程序选项从 shell 命令提示符编译它。使用以下语法$ nvfortran hello.f
默认情况下,可执行输出放置在文件
a.out
中。但是,您可以使用o
选项指定输出文件名。要将可执行输出放置在文件 hello 中,请使用此命令
$ nvfortran -o hello hello.f
执行程序。要执行生成的 hello 程序,只需在命令提示符下键入文件名,然后按键盘上的 Return 或 Enter 键
$ hello
以下是预期输出
hello
1.3. 调用命令行 NVIDIA HPC 编译器
要翻译和链接 Fortran、C 或 C++ 程序,nvfortran
、nvc
和 nvc++
命令执行以下操作
预处理源文本文件。
检查源文本的语法。
生成汇编语言文件。
将控制权传递给后续的汇编和链接步骤。
1.3.1. 命令行语法
编译器命令行语法,以 nvfortran 为例,如下所示
nvfortran [options] [path]filename [...]
其中
- options
是一个或多个命令行选项,所有这些选项都在 使用命令行选项 中详细描述。
- path
是包含 filename 指定的文件的目录的路径名。如果您未指定文件名的路径,则编译器将使用当前目录。您必须为当前目录中不存在的每个文件名单独指定路径。
- filename
是要由编译系统处理的源文件、预处理源文件、汇编语言文件、目标文件或库的名称。您可以指定多个 [path]filename。
1.3.2. 命令行选项
命令行选项控制编译过程的各个方面。有关所有命令行选项的完整字母列表和描述,请参阅 使用命令行选项。
以下列表提供了有关正确使用命令行选项的重要信息。
命令行选项及其参数区分大小写。
编译器驱动程序将以连字符 (-) 开头的字符识别为命令行选项。例如,
-Mlist
选项指定编译器创建列表文件。注意
本手册文本的约定是使用短划线而不是连字符来显示命令行选项;例如,您会看到
-Mlist
。选项和文件名的顺序是灵活的。也就是说,您可以将选项放置在命令行上文件名参数之前和之后。但是,某些选项的位置很重要,例如 -l 选项,其中文件名的顺序决定了搜索顺序。
注意
如果两个或多个选项相互矛盾,则命令行中最后一个选项优先。
您可以将链接器选项写入以“@”符号为前缀的文本文件中,例如
@file
,并将该文件作为选项传递给编译器。@file
的内容将传递给链接器。$ echo "foo.o bar.o" > ./option_file.rsp $ nvc++ @./option_files.rsp
上述操作会将“foo.o bar.o”作为链接器参数传递给编译器。
1.4. 文件名约定
NVIDIA HPC 编译器使用您在命令行上指定的文件名来查找和创建输入和输出文件。本节介绍了编译过程各个阶段的输入和输出文件名约定。
1.4.1. 输入文件
您可以在命令行上指定汇编语言文件、预处理源文件、Fortran/C/C++ 源文件、目标文件和库作为输入。编译器驱动程序通过检查文件名扩展名来确定每个输入文件的类型。
驱动程序使用以下约定
filename.f
表示 Fortran 源文件。
filename.F
表示可以包含宏和预处理器指令的 Fortran 源文件(要预处理)。
filename.FOR
表示可以包含宏和预处理器指令的 Fortran 源文件(要预处理)。
filename.F90
表示可以包含宏和预处理器指令的 Fortran 90/95 源文件(要预处理)。
filename.F95
表示可以包含宏和预处理器指令的 Fortran 90/95 源文件(要预处理)。
filename.f90
表示采用自由格式的 Fortran 90/95 源文件。
filename.f95
表示采用自由格式的 Fortran 90/95 源文件。
filename.cuf
表示采用自由格式且带有 CUDA Fortran 扩展的 Fortran 90/95 源文件。
filename.CUF
表示采用自由格式且带有 CUDA Fortran 扩展且可以包含宏和预处理器指令的 Fortran 90/95 源文件(要预处理)。
filename.c
表示可以包含宏和预处理器指令的 C 源文件(要预处理)。
filename.C
表示可以包含宏和预处理器指令的 C++ 源文件(要预处理)。
filename.i
表示预处理的 C 或 C++ 源文件。
filename.cc
表示可以包含宏和预处理器指令的 C++ 源文件(要预处理)。
filename.cpp
表示可以包含宏和预处理器指令的 C++ 源文件(要预处理)。
filename.s
表示汇编语言文件。
filename.o
(Linux)表示目标文件。
filename.a
(Linux)表示目标文件库。
filename.so
(仅限 Linux)表示共享对象文件库。
驱动程序将扩展名为 .s
的文件传递给汇编器,并将扩展名为 .o
、.so
和 .a
的文件传递给链接器。扩展名无法识别或没有扩展名的输入文件也会传递给链接器。
带有 .F
(大写 F) 或 .FOR
后缀的文件首先由 Fortran 编译器进行预处理,然后将输出传递到编译阶段。Fortran 预处理器的功能类似于 C 程序的 cpp,但它是内置于 Fortran 编译器中的,而不是通过调用 cpp 来实现的。这种设计确保了预处理步骤的一致性,而与您编译时所用的操作系统类型或版本无关。
任何特定处理阶段不需要的输入文件都不会被处理。例如,如果在命令行中指定一个汇编语言文件 (filename.s
) 和 -S
选项以在汇编阶段之前停止,则编译器不会对汇编语言文件执行任何操作。处理在编译后停止,汇编器不会运行。在这种情况下,编译必须在之前的过程中完成,该过程创建了 .s
文件。有关 -S
选项的完整描述,请参阅 输出文件。
除了在命令行中指定主输入文件外,还可以使用 Fortran 源代码文件中的 INCLUDE 语句或使用 .F
扩展名的 Fortran 源代码文件以及 C++ 和 C 源代码文件中的预处理器 #include 指令,将其他文件中的代码编译为 include 文件的一部分。
当将程序与库链接时,链接器仅提取程序需要的那些库组件。编译器驱动程序默认链接多个库。有关库的更多信息,请参阅 创建和使用库。
1.4.2. 输出文件
默认情况下,NVIDIA HPC 编译器之一生成的可执行输出文件放置在 a.out
文件中。正如 Hello 示例 所示,您可以使用 -o
选项来指定输出文件名。
如果您使用选项 -F
(仅限 Fortran)、-P
(仅限 C/C++)、-S
或 -c
,编译器会为每个输入文件生成一个包含最后一个已完成阶段输出的文件,具体取决于提供的选项。
输出文件分别是预处理的源文件、汇编语言文件或未链接的目标文件。类似地,-E
选项不会生成文件,而是在标准输出上显示预处理的源文件。使用这些选项中的任何一个,只有在指定单个输入文件时,-o
选项才有效。如果在处理过程中没有发生错误,您可以将这些选项创建的文件用作将来调用任何 NVIDIA 编译器驱动程序的输入。
下表列出了 stop-after 选项以及当您使用这些选项时编译器创建的输出文件。它还指示了接受的输入文件。
选项 |
停止在 |
输入 |
输出 |
---|---|---|---|
|
预处理 |
源文件 |
预处理文件到标准输出 |
|
预处理 |
源文件。此选项对 nvc 或 nvc++ 无效。 |
预处理文件 ( |
|
预处理 |
源文件。此选项对 nvfortran 无效。 |
预处理文件 ( |
|
编译 |
源文件或预处理文件 |
汇编语言文件 ( |
|
汇编 |
源文件、预处理文件或汇编语言文件 |
未链接的目标文件 ( |
无 |
链接 |
源文件、预处理文件、汇编语言文件、目标文件或库 |
可执行文件 ( |
如果您指定多个输入文件或未指定目标文件名,编译器将使用输入文件名来派生相应的默认输出文件名,格式如下,其中 *filename* 是不带扩展名的输入文件名
filename.f
指示预处理文件,如果您使用
-F
选项编译 Fortran 文件。filename.i
指示预处理文件,如果您使用
-P
选项进行编译。filename.lst
指示来自
-Mlist
选项的列表文件。filename.o
或filename.obj
指示来自
-c
选项的目标文件。filename.s
指示来自
-S
选项的汇编语言文件。
注意
除非您另行指定,否则任何输出文件的目标目录都是当前工作目录。如果文件存在于目标目录中,编译器将覆盖它。
以下示例演示了输出文件名扩展名的用法。
$ nvfortran -c proto.f proto1.F
这将生成输出文件 proto.o
和 proto1.o
,它们是二进制目标文件。在编译之前,文件 proto1.F
会被预处理,因为它具有 .F
文件名扩展名。
1.5. Fortran、C++ 和 C 数据类型
NVIDIA Fortran、C++ 和 C 编译器识别标量和聚合数据类型。标量数据类型保存单个值,例如整数值 42 或实数值 112.6。聚合数据类型由一个或多个标量数据类型对象组成,例如整数值数组。
1.6. 平台特定注意事项
NVIDIA HPC 编译器在运行 Linux 的 x86-64 和 64 位 Arm 多核 CPU 上受支持。
1.6.1. 在 Linux 上使用 NVIDIA HPC 编译器
Linux 头文件
Linux 系统头文件包含许多 GNU gcc 扩展。NVIDIA HPC C++ 和 C 编译器支持许多这些扩展,并且可以编译 GNU 编译器可以编译的大多数程序。一些与 NVIDIA 编译器不兼容的头文件已被重写。
如果您正在使用 NVIDIA HPC C++ 或 C 编译器,请确保在系统版本之前找到提供的这些包含文件版本。除非您显式添加引用系统 include
目录之一的 -I 选项,否则此层次结构默认发生。
1.7. 编译器的站点特定定制
如果您正在使用 NVIDIA HPC 编译器,并希望所有用户都可以访问特定的库或其他文件,则有一些特殊文件允许您为您的站点定制编译器。
1.7.1. 使用 siterc 文件
NVIDIA HPC 编译器命令行驱动程序使用名为 siterc
的文件来启用 NVIDIA 编译器行为的站点特定定制。siterc
文件位于 NVIDIA HPC 编译器安装目录的 bin
子目录中。使用 siterc
,您可以控制编译器驱动程序如何调用编译工具链中的各种组件。
1.7.2. 使用 User rc 文件
除了 siterc 文件之外,用户 rc
文件可以驻留在给定用户的主目录中,如用户的 HOME 环境变量指定的那样。您可以使用这些文件来控制相应的 NVIDIA HPC 编译器。所有这些文件都是可选的。
在 Linux 上,这些文件名为 .mynvfortranrc
、.mynvcrc
和 .mynvc++rc
。
以下示例展示了如何使用这些 rc 文件为 Linux_x86_64
目标定制给定的安装。对于 aarch64
目标,该过程类似,只需进行明显的替换。
要做到这一点… |
将显示的行添加到指示的文件中 |
---|---|
使所有 linux 编译都可以使用 /opt/newlibs/64 中找到的库 |
|
向所有 linux 编译添加新的库路径:/opt/local/fast |
|
对于 linux 编译,将 -Mmpi 更改为链接到 /opt/mympi/64/libmpix.a |
|
构建一个用于 linux 的 Fortran 可执行文件,该文件解析相对目录 ./REDIST 中的共享对象 |
|
1.8. 常用开发任务
既然您已经对编译器有了简要的介绍,那么让我们来看看您可能希望执行的一些常用开发任务。
当您编译代码时,可以在命令行中指定许多选项,这些选项定义了与程序编译和链接方式相关的特定特征,通常会增强或覆盖编译器的默认行为。有关最常见的命令行选项列表以及所有命令行选项的信息,请参阅 使用命令行选项。
针对多核 CPU 的代码优化允许编译器组织您的代码以实现高效执行。虽然可能会增加编译时间并使代码更难以调试,但这些技术通常会生成比不使用这些技术的代码运行速度快得多的代码。有关优化的更多信息,请参阅 多核 CPU 优化。
函数内联是一种特殊的优化类型,它用函数或子例程的主体替换对函数或子例程的调用。此过程可以通过消除参数传递以及函数或子例程调用和返回开销来加快执行速度。此外,函数内联允许编译器使用代码的其余部分优化函数。但是,函数内联也可能导致代码大小大幅增加,而执行速度没有提高。有关函数内联的更多信息,请参阅 使用函数内联。
库是用于开发软件的函数或子程序集合。库包含“辅助”代码和数据,它们为独立程序提供服务,从而允许以模块化方式共享和更改代码和数据。库中的函数和程序被分组以便于使用和链接。在创建程序时,通常使用合并标准库或专有库。有关此主题的更多信息,请参阅 创建和使用库。
环境变量定义了一组动态值,这些值会影响运行进程在计算机上的行为方式。通常使用这些变量来设置和传递信息,这些信息会更改 NVIDIA HPC 编译器及其生成的执行文件的默认行为。有关这些变量的更多信息,请参阅 环境变量。
部署虽然可能是一项不常见的任务,但可能会出现一些与将代码移植到其他系统相关联的独特问题。在这种上下文中,部署涉及分发已编译和配置的特定文件或文件集。分发必须以确保应用程序在另一个系统上准确执行的方式进行,而该系统的配置可能与创建代码的系统完全不同。有关您可能需要了解的有关成功部署代码的更多信息,请参阅 分发文件 – 部署。
内在函数是给定语言中可用的函数,其实现由编译器专门处理。内在函数使使用处理器特定的增强功能更容易,因为它们为汇编指令提供了 C++ 和 C 语言接口。这样做,编译器管理用户通常需要关心的细节,例如寄存器名称、寄存器分配和数据的内存位置。
2. 使用命令行选项
命令行选项允许您控制程序编译和链接时的特定行为。本节介绍正确使用命令行选项的语法,并简要概述一些更常见的选项。
2.1. 命令行选项概述
在查看所有命令行选项之前,首先熟悉这些选项的语法。您可以使用大量选项,但大多数用户只使用其中的几个选项。因此,从简单开始,逐步使用更高级的选项。
默认情况下,NVIDIA HPC 编译器生成的代码针对执行编译的处理器类型(即编译主机)进行了优化。在向命令行添加选项之前,请查看 命令行选项帮助 和 常用选项。
2.1.1. 命令行选项语法
在命令行中,选项需要以连字符 (-) 开头。如果编译器无法识别某个选项,您将收到未知开关错误。通过添加 -noswitcherror
选项,可以将错误降级为警告。
本文档在描述选项时使用以下符号
- [item]
方括号表示括起来的项目是可选的。
- {item | item}
花括号表示您必须选择且仅选择一个括起来的项目。竖线 (|) 分隔选项。
- …
水平省略号表示零个或多个前述项目的实例是有效的。
2.1.2. 命令行子选项
某些选项接受多个子选项。您可以通过多次使用完整选项语句或使用逗号分隔的子选项列表来指定这些子选项。
以下两个命令行是等效的
nvfortran -Mvect=simd -Mvect=noaltcode
nvfortran -Mvect=simd,noaltcode
2.1.3. 命令行冲突选项
某些选项具有相反或否定的对应项。例如,-Mvect
和 -Mnovect
都是可用的。-Mvect
启用向量化,而 -Mnovect
禁用向量化。如果您在命令行中同时使用了这两个命令,它们将发生冲突。
注意
当您在命令行中使用冲突的选项时,最后遇到的选项优先于任何之前的选项。
冲突选项规则非常重要,原因有很多。
某些选项(例如
-fast
)包含其他选项。因此,您可能会在不知情的情况下遇到冲突的选项。您可以使用此规则创建将特定标志应用于一组文件的 makefile,如下例所示。
示例:带有选项的 Makefile
在此 makefile 片段中,CCFLAGS 使用向量化。CCNOVECTFLAGS 使用为 CCFLAGS 定义的标志,但禁用向量化。
CCFLAGS=c -Mvect=simd
CCNOVECTFLAGS=$(CCFLAGS) -Mnovect
2.2. 命令行选项帮助
如果您刚开始使用 NVIDIA HPC 编译器,了解哪些选项可用、何时使用它们以及哪些选项大多数用户认为有效会很有帮助。
使用 -help
-help
选项非常有用,因为它提供了有关给定编译器支持的所有选项的信息。
您可以通过以下三种方式之一使用 -help
使用不带参数的
-help
可获取所有可用选项的列表,以及每个选项的简短单行描述。向
-help
添加参数以将输出限制为有关特定选项的信息。此用法的语法为-help <command line option>
假设您使用以下命令将输出限制为有关 -fast 选项的信息
$ nvfortran -help -fast
您看到的输出类似于
-fast Common optimizations; includes -O2 -Munroll=c:1 -Mnoframe -Mlre
在以下示例中,我们添加了
-help
参数以将输出限制为有关 help 命令的信息。-help
的用法信息显示了如何根据功能列出或检查选项组。$ nvfortran -help -help -help[=groups|asm|debug|language|linker|opt|other|overall|phase|prepro| suffix|switch|target|variable]
向
-help
添加参数以将输出限制为一组特定的选项或构建过程。此用法的语法如下-help=<subgroup>
2.3. 性能入门
本节快速概述了一些可用于提高多核 CPU 性能的命令行选项。
2.3.1. 使用 -fast
NVIDIA HPC 编译器实现了一系列广泛的选项,允许用户对每个优化阶段进行精细的控制。当涉及到代码优化时,最快的入门方法是使用选项 -fast
。这些选项创建了一组通常最佳的标志。它们结合了优化选项,以启用对 64 位目标使用向量流式 SIMD 指令。它们启用使用 SIMD 指令进行向量化、缓存对齐和刷新为零模式。
注意
-fast
选项的内容是主机相关的。此外,您应该在编译和链接命令行上都使用这些选项。
下表显示了典型的 -fast
选项。
使用此选项… |
要做到这一点… |
---|---|
|
指定代码优化级别为 2。 |
|
展开循环,在每次迭代期间执行原始循环的多个实例。 |
|
不生成代码来设置堆栈帧。注意:使用此选项,堆栈跟踪不起作用。 |
|
启用循环携带冗余消除。 |
|
启用部分冗余消除 |
在大多数现代 CPU 上,-fast
还包括下表所示的选项
使用此选项… |
要做到这一点… |
---|---|
|
生成打包的 SIMD 指令。 |
|
将长对象对齐到缓存行边界。 |
|
设置刷新为零模式。 |
|
控制自动向量流水线。 |
要查看 -fast
对您的目标的具体行为,请使用以下命令
$ nvfortran -help -fast
2.4. 常用选项
除了整体性能外,还有许多其他选项,许多用户在入门时发现这些选项很有用。下表简要概述了这些选项。
使用此选项… |
要做到这一点… |
---|---|
|
使用 OpenACC 指令启用并行化。默认情况下,编译器将并行化 OpenACC 区域并将其卸载到 NVIDIA GPU。使用 |
|
此选项为支持 SIMD 功能的目标创建一组通常最佳的标志。它结合了优化选项,以启用向量流式 SIMD 指令、缓存对齐和 flushz 的使用。 |
|
指示编译器在目标模块中包含符号调试信息;除非命令行中存在 |
|
指示编译器在目标文件中包含符号调试信息,并生成与未指定 |
|
控制为其生成代码的 GPU 类型、要定位的 CUDA 版本以及 GPU 代码生成的其他几个方面。 |
|
提供有关可用选项的信息。 |
|
为 64 位目标启用 medium=model 代码生成,当程序的数据空间超过 4GB 时,这很有用。 |
|
使用 OpenMP 指令启用并行化。默认情况下,编译器将并行化 OpenMP 区域以在多核 CPU 的所有内核上执行。使用 |
|
指示编译器启用循环的自动并发化。如果指定,编译器将使用多个 CPU 内核来执行它确定为可并行化的循环;因此,循环迭代被拆分以在多线程执行上下文中最佳地执行。 |
|
指示编译器在标准错误中生成信息。 |
|
启用函数内联。 |
|
启用过程间分析和优化。还启用自动过程内联。 |
|
保留生成的汇编文件。 |
|
调用循环展开器来展开循环,在每次迭代期间执行循环的多个实例。如果级别设置为小于 2,或者如果未提供 -O 或 -g 选项,这也会将优化级别设置为 2。 |
|
启用 [禁用] 代码向量化器。 |
|
从用户代码中删除异常处理。对于 C++,声明此文件中的函数不生成 C++ 异常,从而允许更优化的代码生成。 |
|
命名输出文件。 |
|
指定代码优化级别,其中 <level> 为 0、1、2、3 或 4。 |
|
启用标准 C++ 和 Fortran 并行构造的并行化和卸载到 NVIDIA GPU;默认值为 -stdpar=gpu。 |
|
指定 CPU 目标,而不是编译主机 CPU。 |
|
编译器驱动程序将指定的选项传递给链接器。 |
2.5. 浮点亚正规数
从 NVIDIA HPC SDK 的 22.7 版本开始,在 x86_64 和 aarch64 处理器上运行时处理浮点非正规(IEEE 754 术语“亚正规”)值的默认设置已更改为更加一致。
非正规值既可以是浮点运算的操作数,也可以是结果。x86_64 ISA 区分了操作数和结果这两个类别,并使用术语“daz”(非正规操作数为零)和“flushz”(结果刷新为零)。Arm V8 ISA 定义可以区分这两个类别,但目前 NVIDIA HPC SDK 支持的处理器对于操作数和结果只有一个设置,并在浮点状态和控制寄存器中定义为“fz”。
NVIDIA HPC SDK C、C++ 和 Fortran 编译器具有命令行开关 -M[no]daz
和 -M[no]flushz
,当为 C/C++ 主函数或 Fortran 主程序指定这些开关时,它们会影响处理器在运行时如何处理非规格化数。这两个命令行开关的值会传递给运行时库,以在程序启动时配置浮点状态和控制寄存器。
NVIDIA HPC SDK 支持来自 Intel 和 AMD 的 x86_64 处理器以及 ArmV8.1 及更高版本的处理器。下表总结了 22.7 版本发布前后 -Mdaz
和 -Mflushz
命令行开关的默认设置。
22.7 之前的默认值 |
22.7 的默认值 |
|
---|---|---|
Intel |
|
|
AMD |
|
|
Arm 处理器 |
|
|
在 NVIDIA HPC SDK 22.7 版本中,非规格化操作数和结果的默认处理方式是将它们视为零,就像主函数/程序使用 -Mdaz-Mflushz
编译一样。因此,这些更改可能会影响依赖于非零亚正常值的应用程序。
除了更改非规格化值的默认处理方式外,用户现在可以通过 NVCOMPILER_FPU_STATE
环境变量配置浮点状态和控制寄存器 — 有效地覆盖程序的原始编译方式。有关更多信息,请参阅 NVCOMPILER_FPU_STATE 环境变量的描述。
3. 多核 CPU 优化
可读、可维护且产生正确结果的源代码并不总是为了高效执行而组织的。通常,程序开发过程的第一步是生成可以执行并产生正确结果的代码。第一步通常涉及在没有过多优化考虑的情况下进行编译。在代码编译和调试之后,代码优化和并行化就成为问题。
使用某些选项调用 NVIDIA HPC 编译器命令会指示编译器生成优化的代码。由于优化会增加编译时间并且可能使调试变得困难,因此并非总是执行优化。但是,优化会产生更高效的代码,通常比未优化的代码运行速度快得多。
编译器根据指定的优化级别优化代码。您可以使用许多选项来指定优化级别,包括 -O
、-Mvect
、-Mipa
和 -Mconcur
。此外,您可以使用多个 -M<nvflag>
开关来控制特定类型的优化。
本章描述了 NVIDIA HPC 编译器支持的优化选项的总体效果,以及几个选项的基本用法。
3.1. 优化概述
一般来说,优化涉及使用转换和替换来生成更高效的代码。这由编译器完成,并涉及独立于特定目标处理器架构的替换,以及利用架构、指令集和寄存器的替换。
为了便于讨论,我们将优化分类为
3.1.1. 局部优化
基本块是语句序列,其中控制流在开头进入,在结尾离开,除了结尾处之外,不可能发生分支。局部优化在程序基本块内的逐块基础上执行。
NVIDIA HPC 编译器执行多种类型的局部优化,包括:代数恒等式消除、常量折叠、公共子表达式消除、冗余加载和存储消除、调度、强度缩减和窥孔优化。
3.1.2. 全局优化
此优化在子程序/函数的所有基本块上执行。优化器对整个程序单元执行控制流和数据流分析。所有循环,包括由诸如 IF 或 GOTO 之类的临时分支形成的循环,都会被检测和优化。
全局优化包括:常量传播、复制传播、死存储消除、全局寄存器分配、循环不变代码外提和归纳变量消除。
3.1.3. 循环优化:展开、向量化和并行化
某些类别的循环的性能可以通过向量化或展开选项来提高。向量化转换循环以提高内存访问性能,并利用打包的 SSE 向量指令,这些指令同时对多个数据项执行相同的操作。展开复制循环体以减少循环分支开销,并为局部优化、向量化和指令调度提供更好的机会。在使用 NVIDIA HPC 编译器的并行化功能的具有多个处理器的系统上,循环的性能也可能得到提高。
3.1.4. 过程间分析 (IPA) 和优化
过程间分析 (IPA) 允许使用跨函数调用边界的信息来执行原本不可用的优化。例如,如果函数的实际参数实际上是调用者中的常量,则可以将该常量传播到被调用者中,并执行如果将虚参数视为变量则无效的优化。使用 IPA 可以启用或改进各种优化,包括但不限于数据对齐优化、参数删除、常量传播、指针消除歧义、纯函数检测、F90/F95 数组形状传播、数据放置、空函数删除、自动函数内联、来自预编译库的函数内联以及来自预编译库的函数的过程间优化。
3.1.5. 函数内联
此优化允许用函数体的副本替换对函数的调用。这种优化有时可以通过消除函数调用和返回开销来加速执行。函数内联也可能为其他类型的优化创造机会。函数内联并非总是有益的。不当使用时,它可能会增加代码大小并生成效率较低的代码。
3.2. 优化入门
首先应该关注的是让程序执行并产生正确的结果。为了让程序运行起来,首先在没有优化的情况下进行编译和链接。在编译行中添加 -O0 以选择不进行优化;或者添加 -g 以轻松调试程序并隔离移植期间暴露的任何编码错误。
为了快速开始优化,与任何 NVIDIA HPC 编译器一起使用的一组好的选项是 -fast。例如
$ nvfortran -fast -Mipa=fast,inline prog.f
对于所有 NVIDIA HPC Fortran、C++ 和 C 编译器,-fast -Mipa=fast,inline
选项通常会生成良好优化的代码,而不会因病态情况而导致明显的减速。
``-fast`` 选项是一个聚合选项,其中包含许多单独的 NVIDIA 编译器选项;包含哪些编译器选项取决于执行编译的目标。
-Mipa=fast,inline
选项调用过程间分析 (IPA),包括多个 IPA 子选项。inline 子选项启用带有 IPA 的自动内联。如果您不希望使用自动内联,则可以使用-Mipa=fast
进行编译,并使用多个不带内联的 IPA 子选项。
这些聚合选项包含通常最优的一组标志,用于支持 SIMD 功能的目标,包括使用 SIMD 指令进行向量化、缓存对齐和 flushz。
下表显示了典型的 -fast
选项。
使用此选项… |
要做到这一点… |
---|---|
|
指定代码优化级别为 2 和 -Mvect=SIMD。 |
|
展开循环,在每次迭代期间执行原始循环的多个实例。 |
|
指示不生成代码来设置堆栈帧。注意 使用此选项,堆栈跟踪不起作用。 |
|
指示循环携带的冗余消除。 |
|
在 C & C++ 中启用自动函数内联。 |
|
指示部分冗余消除 |
在现代多核 CPU 上,-fast
通常还包括下表所示的选项
使用此选项… |
要做到这一点… |
---|---|
|
生成打包的 SSE 和 AVX 指令。 |
|
将长对象对齐到缓存行边界。 |
|
设置刷新为零模式。 |
通过在逐个文件的基础上试验单个编译器选项,有时可以实现进一步的显着性能提升。但是,根据编码风格,单个优化有时会导致减速,必须谨慎使用以确保性能提升。
还有其他与优化和并行化相关的有用命令行选项,例如 -help、-Minfo、-Mneginfo、-dryrun 和 -v。
3.2.1. -help
如 命令行选项帮助 中所述,您可以通过调用任何带有 -help
以及所讨论的选项的 NVIDIA HPC 编译器来查看任何命令行选项的规范,而无需指定任何输入文件。
例如,您可能需要有关 -O
的信息
$ nvfortran -help -O
结果输出类似于这样
-O Set opt level. All -O1 optimizations plus traditional scheduling and
global scalar optimizations performed
或者,您可以查看 -help
本身的全部功能,它可以返回有关单个选项或选项组的信息
$ nvfortran -help -help
结果输出类似于这样
-help[=groups|asm|debug|language|linker|opt|other|overall|
phase|prepro|suffix|switch|target|variable]
Show compiler switches
3.2.2. -Minfo
您可以使用 -Minfo
选项来显示编译时优化列表。使用此选项时,NVIDIA HPC 编译器会在编译过程中向标准错误 (stderr) 发出信息性消息。从这些消息中,您可以确定哪些循环使用展开、SIMD 向量化、并行化、GPU 卸载、过程间优化和各种其他优化进行了优化。您还可以查看函数内联的位置和方式。
3.2.3. -Mneginfo
您可以使用 -Mneginfo
选项向标准错误 (stderr) 显示信息性消息,这些消息解释了为什么某些优化受到抑制。
3.2.4. -dryrun
如果您需要查看编译器驱动程序在给定一组命令行输入的情况下用于预处理、编译、汇编和链接的步骤,则 -dryrun
选项可能用作诊断工具。当您指定 -dryrun
选项时,这些步骤将打印到标准错误 (stderr),但实际上并未执行。例如,您可以使用此选项来检查在链接阶段搜索的默认库和用户指定库,以及链接器搜索它们的顺序。
3.2.5. -v
-v
选项类似于 -dryrun,不同之处在于每个编译步骤都会执行,而不仅仅是打印出来。
3.3. 局部和全局优化
本节介绍局部和全局优化。
3.3.1. -Msafeptr
在已知没有指针别名的情况下,-Msafeptr
选项可以显着提高 C++ 和 C 程序的性能。由于显而易见的原因,必须谨慎使用此命令行选项。-Msafeptr
有许多子选项
-Msafeptr=all
– 所有指针都是安全的。等效于默认设置:-Msafeptr
。-Msafeptr=arg
– 函数形式参数指针是安全的。等效于-Msafeptr=dummy
。-Msafeptr=global
– 全局指针是安全的。-Msafeptr=local
– 局部指针是安全的。等效于-Msafeptr=auto
。-Msafeptr=static
– 静态局部指针是安全的。
如果您的 C++ 或 C 程序具有指针别名,并且您还想要自动内联,则使用 -Mipa=fast
或 -Mipa=fast,inline
进行编译会包含指针别名优化。IPA 可能能够优化程序中的某些别名引用,并保留那些无法安全优化的引用。
3.3.2. -O
使用带有 -O
<level> 选项(大写 O 代表优化)的 NVIDIA HPC 编译器命令,您可以指定从 0 到 4 的任何整数级别。
-O0
级别零指定不进行优化。为每个语言语句生成一个基本块。在此级别,编译器为每个语句生成一个基本块。
使用此优化级别时,性能几乎总是最慢的。此级别对于程序的初始执行很有用。它对于调试也很有用,因为程序文本和生成的代码之间存在直接关联。要启用调试,请在编译行中包含 -g
。
-O1
级别一指定局部优化。执行基本块的调度。执行寄存器分配。
当代码非常不规则时,局部优化是一个不错的选择,例如包含许多包含 IF 语句的短语句且不包含循环(DO 或 DO WHILE 语句)的代码。虽然这种情况很少发生,但对于某些类型的代码,此优化级别可能比级别二 (-O2) 性能更好。
-O
当未指定级别时,执行级别二全局优化,包括传统的标量优化、归纳识别和循环不变代码外提。未启用 SIMD 向量化。
-O2
级别二指定全局优化。此级别执行级别一局部优化以及 -O
中描述的级别二全局优化。此外,还启用了更高级的优化,例如 SIMD 代码生成、缓存对齐和部分冗余消除。
-O3
级别三指定激进的全局优化。此级别执行级别一和级别二的所有优化,并启用可能有利也可能不利的更激进的外提和标量替换优化。
-O4
级别四执行级别一、级别二和级别三的所有优化,并启用受保护的不变浮点表达式的外提。
优化类型
NVIDIA HPC 编译器执行许多不同类型的局部优化,包括但不限于
代数恒等式消除
常量折叠
公共子表达式消除
局部寄存器优化
窥孔优化
冗余加载和存储消除
强度缩减
级别二优化 (-O2
或 -O
) 指定全局优化。-fast
选项通常指定全局优化;但是,-fast
开关因版本而异,具体取决于任何特定版本的合理开关选择。-O
或 -O2
级别执行所有级别一局部优化以及全局优化。应用控制流分析,并为所有函数和子例程分配全局寄存器。循环区域得到特别考虑。当程序包含循环、循环短且代码结构规则时,此优化级别是一个不错的选择。
NVIDIA HPC 编译器执行许多不同类型的全局优化,包括但不限于
分支到分支消除
常量传播
复制传播
死存储消除
全局寄存器分配
归纳变量消除
循环不变代码外提
您可以在命令行上显式选择优化级别。例如,以下命令行指定级别二优化,这将导致全局优化
$ nvfortran -O2 prog.f
默认优化级别会根据您在命令行上选择的选项而变化。例如,当您选择 -g
调试选项时,默认优化级别设置为级别零 (-O0
)。但是,如果您需要调试优化的代码,则可以使用 -gopt
选项来生成调试信息,而不会扰乱优化。有关默认级别的描述,请参阅默认优化级别。
-fast
选项在所有目标上都包含 -O2
。如果您想使用 -O3
覆盖 -fast
的默认值,同时保持 -fast
的所有其他元素,只需按如下方式编译
$ nvfortran -fast -O3 prog.f
3.4. 使用 -Munroll 进行循环展开
此优化展开循环,从而减少分支开销,并通过为指令调度创造更好的机会来提高执行速度。具有常量计数的循环可以完全展开或部分展开。具有非常量计数的循环也可以展开。候选循环必须是包含一到四个代码块的最内层循环。
以下示例显示了 -Munroll
选项的用法
$ nvfortran -Munroll prog.f
-Munroll
选项包含在所有目标上的 -fast
中。循环展开器扩展循环的内容并减少循环执行的次数。当循环展开两次或更多次时,分支开销会减少,因为展开循环的每次迭代对应于原始循环的两次或更多次迭代;执行的分支指令的数量成比例地减少。当循环完全展开时,循环的分支开销将完全消除。
循环展开可能对指令调度器有利。当循环完全展开或展开两次或更多次时,可能会出现改进调度的机会。代码生成器可以利用更多可能性进行指令分组或填充循环内发现的指令延迟。
显示展开效果的示例
以下并排示例显示了代码展开对计算点积的段的影响。
注意
此示例仅旨在表示编译器如何转换循环;并非旨在暗示程序员需要手动更改代码。事实上,手动展开代码有时会抑制编译器的分析和优化。
点积代码 |
展开的点积代码 |
---|---|
REAL*4 A(100), B(100), Z
INTEGER I
DO I=1, 100
Z = Z + A(i) * B(i)
END DO
END
|
REAL*4 A(100), B(100), Z
INTEGER I
DO I=1, 100, 2
Z = Z + A(i) * B(i)
Z = Z + A(i+1) * B(i+1)
END DO
END
|
使用 -Minfo 选项,编译器会在循环展开时通知您。例如,当循环展开时,会显示类似于以下内容的消息,指示行号和代码展开的次数
dot:
5, Loop unrolled 5 times
使用 -Munroll
的 c:<m> 和 n:<m> 子选项,或使用 -Mnounroll
,您可以控制是否以及如何在逐个文件的基础上展开循环。有关 -Munroll
的更多信息,请参阅 使用命令行选项。
3.5. 使用 -Mvect 进行向量化
-Mvect
选项包含在所有多核 CPU 目标上的 -fast
中。如果您的程序包含计算密集型循环,则 -Mvect
选项可能很有用。如果此外您指定 -Minfo
,并且您的代码包含可以向量化的循环,则编译器会报告有关应用的优化的相关信息。
当使用 -Mvect
选项调用 NVIDIA HPC 编译器命令时,向量化器会扫描代码,搜索适合高级转换的循环,例如循环分发、循环交换、缓存平铺和惯用语识别(用优化的代码序列或函数调用替换可识别的代码序列,例如归约循环)。当向量化器找到向量化机会时,它会在内部重新排列或替换循环的Sections(向量化器会更改生成的代码;您的源代码的循环不会更改)。除了执行这些循环转换外,向量化器还生成广泛的数据依赖性信息,供编译的其他阶段使用,并检测在支持这些指令的处理器上使用向量或打包 SIMD 指令的机会。
-Mvect
选项可以加速包含行为良好的可计数循环的代码,这些循环在 Fortran 及其 C++ 和 C 对应项中对大型浮点数组进行操作。但是,某些代码在使用 -Mvect
选项编译时可能会出现性能下降,这是由于生成了有条件执行的代码段、无法确定数据对齐以及其他代码生成因素。因此,建议您仔细检查特定程序单元或循环在使用此选项启用编译时是否显示出性能改进。
3.5.1. 向量化子选项
向量化器对可计数循环执行高级循环转换。如果循环迭代次数仅在循环执行之前设置且在循环执行期间无法修改,则该循环是可计数的。一些向量化器转换可以通过 -Mvect
命令行选项的参数来控制。以下Sections描述了影响向量化器操作的参数。此外,可以使用指令和编译指示从代码内部控制其中一些向量化器操作。
向量化器执行以下操作
循环交换
循环拆分
循环融合
在支持这些指令的 CPU 上生成 SIMD 指令
在支持这些指令的处理器上生成预取指令
循环迭代剥离以最大化向量对齐
备用代码生成
下表列出并简要描述了一些 -Mvect
子选项。
使用此选项 … |
指示向量化器执行此操作 … |
---|---|
|
为向量化循环生成适当的代码。 |
|
|
|
启用循环融合。 |
|
启用间接数组引用的向量化。 |
|
启用惯用语识别。 |
|
设置要优化的最大下一级循环。 |
|
禁用带有条件的循环的向量化。 |
|
通过内循环分发启用部分循环向量化。 |
|
遇到可向量化循环时自动生成预取指令,即使在未生成 SSESIMD 指令的情况下也是如此。 |
|
启用短向量运算。 |
|
遇到可向量化循环时自动生成打包的 SSE(流式 SIMD 扩展)SIMD 和预取指令。SIMD 指令最初在 Pentium III 和 AthlonXP 处理器上引入,对单精度浮点数据进行运算。 |
|
限制向量化循环的大小。 |
|
等效于 -Mvect=simd。 |
|
在向量化循环和残余循环中执行一致的优化。请注意,这可能会影响残余循环的性能。 |
注意
在选项前面插入 no
会禁用该选项。例如,要禁用 SIMD 指令的生成,请使用 -Mvect=nosimd 进行编译。
3.5.2. 使用 SIMD 指令的向量化示例
最重要的向量化选项之一是 -Mvect=simd
。当您使用此选项时,编译器会在可能的情况下自动生成 SIMD 向量指令,以针对支持这些指令的处理器。与等效的标量代码相比,此过程可以将性能提高数倍。所有 NVIDIA HPC Fortran、C++ 和 C 编译器都支持此功能。
在 使用 SIMD 指令的向量运算 中的程序中,当使用编译器开关 -Mvect=simd
或 -fast
时,向量化器会识别子例程“loop”中的向量运算。此示例显示了在 Intel Core i7 7800X Skylake 系统上使用 SIMD 指令的编译、信息性消息和运行时结果,以及影响 SIMD 性能的问题。
当处理与缓存行边界对齐的向量时,使用 SIMD 指令向量化的循环运行效率更高。您可以通过使用 -Mcache_align
开关进行编译,使大小为 16 字节或更大的无约束数据对象进行缓存对齐。无约束数据对象是不属于公共块成员且不属于聚合数据结构成员的数据对象。
注意
为了使基于堆栈的局部变量正确对齐,必须使用 -Mcache_align 编译主程序或函数。
-Mcache_align
开关对 Fortran 可分配或自动数组的对齐没有影响。如果您有受约束的数组,例如作为 Fortran 公共块成员的向量,则必须专门填充数据结构以确保正确的缓存对齐。您可以仅对每个公共块的起始地址使用 -Mcache_align
以进行缓存对齐。
以下示例显示了使用和不使用选项 -Mvect=simd
编译 使用 SIMD 指令的向量运算 中的示例代码的结果。
使用 SIMD 指令的向量运算
program vector_op
parameter (N = 9999)
real*4 x(N), y(N), z(N), W(N)
do i = 1, n
y(i) = i
z(i) = 2*i
w(i) = 4*i
enddo
do j = 1, 200000
call loop(x,y,z,w,1.0e0,N)
enddo
print *, x(1),x(771),x(3618),x(6498),x(9999)
end
subroutine loop(a,b,c,d,s,n)
integer i, n
real*4 a(n), b(n), c(n), d(n),s
do i = 1, n
a(i) = b(i) + c(i) - s * d(i)
enddo
end
假设前面的程序按如下方式编译,其中 -Mvect=nosimd
禁用 SIMD 向量化
$ nvfortran -fast -Mvect=nosimd -Minfo vadd.f -Mfree -o vadd
vector_op:
4, Loop unrolled 16 times
Generated 1 prefetches in scalar loop
9, Loop not vectorized/parallelized: contains call
loop:
18, Loop unrolled 8 times
FMA (fused multiply-add) instruction(s) generated
以下输出显示了在 Intel Core i7 7800X Skylake 系统上运行和计时生成的执行文件的示例结果
$ /bin/time vadd
-1.000000 -771.0000 -3618.000 -6498.000
-9999.000
0.99user 0.01system 0:01.18elapsed 84%CPU (0avgtext+0avgdata 3120maxresident)k
7736inputs+0outputs (4major+834minor)pagefaults 0swaps
$ /bin/time vadd
-1.000000 -771.0000 -3618.000 -6498.000
-9999.000
2.31user 0.00system 0:02.57elapsed 89%CPU (0avgtext+0avgdata 6976maxresident)k
8192inputs+0outputs (4major+149minor)pagefaults 0swaps
现在,重新编译并启用向量化,您会看到类似于以下的这些结果
$ nvfortran -fast -Minfo vadd.f -Mfree -o vadd
vector_op:
4, Loop not vectorized: may not be beneficial
Unrolled inner loop 8 times
Residual loop unrolled 7 times (completely unrolled)
Generated 1 prefetches in scalar loop
9, Loop not vectorized/parallelized: contains call
loop:
18, Generated 2 alternate versions of the loop
Generated vector simd code for the loop
Generated 3 prefetch instructions for the loop
Generated vector simd code for the loop
Generated 3 prefetch instructions for the loop
Generated vector simd code for the loop
Generated 3 prefetch instructions for the loop
FMA (fused multiply-add) instruction(s) generated
请注意第 18 行循环的信息性消息。消息的第一行指示生成了循环的两个备用版本。数组的循环计数和对齐方式决定了执行哪个版本。接下来的几行指示循环已向量化,并且已为三个加载生成了预取指令,以最大限度地减少从主内存传输数据时的延迟。
再次执行,您应该看到类似于以下的结果
$ /bin/time vadd-simd
-1.000000 -771.0000 -3618.000 -6498.000
-9999.000
0.27user 0.00system 0:00.29elapsed 93%CPU (0avgtext+0avgdata 3124maxresident)k
0inputs+0outputs (0major+838minor)pagefaults 0swaps
$ /bin/time vadd-simd
-1.000000 -771.0000 -3618.000 -6498.000
-9999.000
0.62user 0.00system 0:00.65elapsed 95%CPU (0avgtext+0avgdata 6976maxresident)k
0inputs+0outputs (0major+151minor)pagefaults 0swaps
SIMD 结果比程序的等效非 SIMD 版本快 3.7 倍。
给定循环或程序实现的加速可能因多种因素而差异很大
当数据向量驻留在数据缓存中时,使用 SIMD 指令的性能提升最为有效。
如果数据对齐正确,则通常情况下,性能会比在未对齐数据上使用 SIMD 操作时更好。
如果编译器可以保证数据对齐正确,则可以生成更高效的 SIMD 指令序列。
操作单精度数据的循环效率可能更高。SIMD 指令可以同时操作四个单精度元素,但只能操作两个双精度元素。
注意
使用 -Mvect=simd
编译可能会导致与使用较少优化生成的执行文件存在数值差异。某些可向量化的操作,例如点积,对操作顺序以及启用向量化(或并行化)所需的结合律变换敏感。
3.6. 使用 -Mipa 进行过程间分析和优化
NVIDIA HPC Fortran、C++ 和 C 编译器使用过程间分析 (IPA),这使得 makefile 和标准编辑-构建-运行应用程序开发周期的更改最小化。除了在命令行中添加 -Mipa
之外,不需要其他更改。为了参考和背景知识,本节稍后将介绍不使用 IPA 构建程序的过程,然后介绍使用 NVIDIA 编译器使用 IPA 所需的少量修改。虽然此处使用 NVC 编译器来展示 IPA 的工作原理,但类似的功能适用于每个 NVIDIA HPC Fortran、C++ 和 C 编译器。
3.6.1. 不使用 IPA 构建程序 – 单步
使用 nvc 命令行编译器驱动程序,可以使用一个命令编译多个源文件并将它们链接到一个可执行文件中。以下示例编译并链接了三个源文件
$ nvc -o a.out file1.c file2.c file3.c
实际上,nvc 驱动程序执行多个步骤来生成与每个源文件对应的汇编代码和目标文件,然后将目标文件链接到单个可执行文件中。此命令大致等效于单独执行的以下命令
$ nvc -S -o file1.s file1.c
$ as -o file1.o file1.s
$ nvc -S -o file2.s file2.c
$ as -o file2.o file2.s
$ nvc -S -o file3.s file3.c
$ as -o file3.o file3.s
$ nvc -o a.out file1.o file2.o file3.o
如果编辑了三个源文件中的任何一个,则可以使用相同的命令行重建可执行文件
$ nvc -o a.out file1.c file2.c file3.c
注意
这始终按预期工作,但具有重新编译所有源文件的副作用,即使只有一个源文件发生了更改。对于具有大量源文件的应用程序,这可能非常耗时且效率低下。
3.6.2. 不使用 IPA 构建程序 – 多步
也可以使用单独的 nvc 命令将每个源文件编译为对应的目标文件,然后使用一个命令将生成的目标文件链接到可执行文件中
$ nvc -c file1.c
$ nvc -c file2.c
$ nvc -c file3.c
$ nvc -o a.out file1.o file2.o file3.o
nvc 驱动程序根据需要调用编译器和汇编器来处理每个源文件,并为最终链接命令调用链接器。如果您修改了其中一个源文件,则可以通过仅编译该文件然后重新链接来重建可执行文件
$ nvc -c file1.c
$ nvc -o a.out file1.o file2.o file3.o
3.6.3. 使用 Make 不使用 IPA 构建程序
程序编译和链接过程可以使用 make
实用程序在支持它的系统上大大简化。假设您创建一个 makefile
,其中包含以下行
a.out: file1.o file2.o file3.o
nvc $(OPT) -o a.out file1.o file2.o file3.o
file1.o: file1.c
nvc $(OPT) -c file1.c
file2.o: file2.c
nvc $(OPT) -c file2.c
file3.o: file3.c
nvc $(OPT) -c file3.c
然后可以键入单个 make 命令
$ make
make
实用程序确定哪些目标文件相对于其对应的源文件已过期,并调用编译器仅重新编译这些源文件并重新链接可执行文件。如果您随后编辑一个或多个源文件,则可以使用相同的单个 make
命令以最少的重新编译次数重建可执行文件。
3.6.4. 使用 IPA 构建程序
NVIDIA HPC 编译器的过程间分析和优化 (IPA) 尽可能少地更改标准和 make
实用程序命令行界面。IPA 分为三个阶段进行
收集:创建每个函数或过程的摘要,收集过程间优化所需的有用信息。如果在命令行中存在
-Mipa
开关,则在编译步骤期间完成此操作;摘要信息将被收集并存储在目标文件中。传播:处理所有目标文件,以在函数和文件边界之间传播过程间摘要信息。如果在链接命令行中存在
-Mipa
开关,则在链接步骤(当所有目标文件组合在一起时)期间完成此操作。重新编译/优化:使用传播的过程间信息重新编译每个目标文件,生成专门的目标文件。如果在链接命令行中存在
-Mipa
开关,则此过程也在链接步骤期间执行。
当使用 -Mipa
链接时,NVIDIA HPC 编译器会自动重新生成每个目标文件的 IPA 优化版本,实际上是重新编译每个文件。如果存在先前构建的 IPA 优化对象,则编译器将通过重用仍然有效的对象来最大限度地缩短重新编译时间。如果 IPA 优化对象比原始目标文件更新,并且该文件的传播 IPA 信息自优化以来未更改,则它们仍然有效。
在重新编译每个目标文件后,将调用常规链接器以使用 IPA 优化的目标文件构建应用程序。IPA 优化的目标文件保存在与原始目标文件相同的目录中,以便在后续程序构建中使用。
3.6.5. 使用 IPA 构建程序 – 单步
通过添加 -Mipa
命令行开关,可以使用一个命令编译多个源文件并使用过程间优化链接它们
$ nvc -Mipa=fast -o a.out file1.c file2.c file3.c
就像不使用 -Mipa
编译一样,驱动程序执行多个步骤来生成汇编和目标文件以创建可执行文件
$ nvc -Mipa=fast -S -o file1.s file1.c
$ as -o file1.o file1.s
$ nvc -Mipa=fast -S -o file2.s file2.c
$ as -o file2.o file2.s
$ nvc -Mipa=fast -S -o file3.s file3.c
$ as -o file3.o file3.s
$ nvc -Mipa=fast -o a.out file1.o file2.o file3.o
在最后一步中,调用 IPA 链接器以读取所有 IPA 摘要信息并执行过程间传播。IPA 链接器在每个目标文件上重新调用编译器,以使用过程间信息重新编译它们。这将创建三个新的具有名称修改的对象
file1_ipa5_a.out.oo.o, file2_ipa5_a.out.oo.o, file3_ipa5_a.out.oo.o
然后调用系统链接器以将这些 IPA 优化对象链接到最终可执行文件中。稍后,如果编辑了三个源文件中的一个,则可以使用相同的命令行重建可执行文件
$ nvc -Mipa=fast -o a.out file1.c file2.c file3.c
这可以工作,但同样具有编译每个源文件以及在链接时重新编译每个目标文件的副作用。
3.6.6. 使用 IPA 构建程序 – 多步
只需添加 -Mipa
命令行开关,就可以使用单独的 nvc 命令编译每个源文件,然后使用一个命令将生成的目标文件链接到可执行文件中
$ nvc -Mipa=fast -c file1.c
$ nvc -Mipa=fast -c file2.c
$ nvc -Mipa=fast -c file3.c
$ nvc -Mipa=fast -o a.out file1.o file2.o file3.o
nvc 驱动程序根据需要调用编译器和汇编器来处理每个源文件,并为最终链接命令调用 IPA 链接器。如果您修改了其中一个源文件,则可以通过仅编译该文件然后重新链接来重建可执行文件
$ nvc -Mipa=fast -c file1.c
$ nvc -Mipa=fast -o a.out file1.o file2.o file3.o
当调用 IPA 链接器时,它将确定 file1.o
的 IPA 优化对象 (file1_ipa5_a.out.oo.o
) 已过时,因为它比对象 file1.o
旧;因此需要重建它,并重新调用编译器以生成它。此外,根据对源文件 file1.c
的更改性质,先前为 file2
和 file3
执行的过程间优化现在可能不准确。例如,IPA 可能已在从 file1.c
中的函数到 file2.c
中的函数的调用中传播了一个常量参数值;如果参数值已更改,则基于该常量值的任何优化都无效。IPA 链接器确定哪些(如果有)先前创建的 IPA 优化对象需要重新生成;并根据需要,重新调用编译器以重新生成它们。仅重新生成过时或具有新的或不同的 IPA 信息的对象。这种方法节省了编译时间。
3.6.7. 使用 Make 和 IPA 构建程序
如前所示,可以使用 make 实用程序使用 IPA 构建程序。只需添加命令行开关 -Mipa
,如下所示
OPT=-Mipa=fast
a.out: file1.o file2.o file3.o
nvc $(OPT) -o a.out file1.o file2.o file3.o
file1.o: file1.c
nvc $(OPT) -c file1.c
file2.o: file2.c
nvc $(OPT) -c file2.c
file3.o: file3.c
nvc $(OPT) -c file3.c
使用单个 make 命令调用编译器以生成任何过期的目标文件,然后调用 nvc 将对象链接到可执行文件中。在链接时,nvc 调用 IPA 链接器以重新生成任何过时或无效的 IPA 优化对象。
$ make
3.6.8. 关于 IPA 的问题
问题: 为什么目标文件如此之大?
答案: 使用 -Mipa
创建的目标文件包含几个附加部分。一个是用于驱动过程间分析的摘要信息。此外,目标文件还包含编译器内部表示的源文件,以便可以在链接时使用过程间优化重新编译该文件。启用内联时可能存在其他信息。目标文件的总大小可能是其原始大小的 5-10 倍。额外的部分不会添加到最终可执行文件中。
问题: 如果我使用 -Mipa
编译,但不使用 -Mipa
链接会怎样?
答案: 即使在编译源文件时使用了 -Mipa
,NVIDIA HPC 编译器也会生成合法的目标文件。如果您使用 -Mipa
编译,但不使用 -Mipa
链接,则链接器将在原始目标文件上调用。将生成合法的可执行文件。虽然此可执行文件不具有过程间优化的好处,但任何其他优化都适用。
问题: 如果我不使用 -Mipa
编译,但使用 -Mipa
链接会怎样?
答案: 在链接时,IPA 链接器必须具有程序中使用的所有函数或例程的摘要信息。只有在使用 -Mipa
编译文件时才会创建此信息。如果您在不使用 -Mipa
的情况下编译文件,然后尝试通过使用 -Mipa
链接来获得过程间优化,则 IPA 链接器将发出消息,指示某些例程没有 IPA 摘要信息,并将继续使用原始目标文件运行系统链接器。如果某些文件使用 -Mipa
编译,而另一些文件未使用 -Mipa
编译,它将确定对于那些未使用 -Mipa
编译的文件,IPA 摘要信息的最安全近似值,并使用该信息重新编译其他文件,使用过程间优化。
问题: 我可以在同一目录中使用 -Mipa
构建多个应用程序吗?
答案: 可以。假设您有三个源文件:main1.c
、main2.c
和 sub.c
,其中 sub.c
在两个应用程序之间共享。假设您使用 -Mipa
构建第一个应用程序,使用以下命令
$ nvc -Mipa=fast -o app1 main1.c sub.c
IPA 链接器创建两个 IPA 优化的目标文件,并使用它们来构建第一个应用程序。
main1_ipa4_app1.oo sub_ipa4_app1.oo
现在假设您使用以下命令构建第二个应用程序
$ nvc -Mipa=fast -o app2 main2.c sub.c
IPA 链接器创建另外两个 IPA 优化的目标文件
main2_ipa4_app2.oo sub_ipa4_app2.oo
注意
现在 sub.c 有三个目标文件:原始 sub.o 和两个 IPA 优化的对象,每个对象用于它出现的应用程序。
问题: IPA 优化目标文件的名称修改方式是怎样的?
答案: 修改后的名称附加了 ‘_ipa’,后跟可执行文件名长度的十进制数,后跟下划线和可执行文件名本身。后缀更改为 .oo,以便链接 *.o 不会拉入 IPA 优化的对象。如果 IPA 链接器确定该文件不会从任何过程间优化中受益,则它不必在链接时重新编译该文件,并使用原始对象。
问题: 我可以将并行 make 环境(例如 pmake)与 IPA 一起使用吗?
答案: 不可以。IPA 与并行 make 环境不兼容。
4. 使用函数内联
函数内联用函数或子例程的主体替换对函数或子例程的调用。这可以通过消除参数传递和函数/子例程调用和返回开销来加速执行。它还允许编译器使用代码的其余部分优化函数。请注意,不加选择地使用函数内联可能会导致代码大小大幅增加,但执行速度却没有提高。
NVIDIA HPC 编译器提供两种类型的内联
自动函数内联 – 在 C++ 和 C 中,您可以使用
inline
关键字和-Mautoinline
选项内联静态函数,-Mautoinline
选项包含在-fast
中。函数内联 – 您可以内联已提取到 Fortran、C++ 和 C 的内联库中的函数。启用函数内联有两种方法:使用和不使用
lib
子选项。对于后者,您创建内联库,例如使用nvfortran
编译器驱动程序以及-o
和-Mextract
选项。
内联存在重要的限制。内联仅适用于某些类型的函数。有关函数内联限制的更多详细信息,请参阅 内联限制。
本节介绍如何使用与函数内联相关的以下选项
-Mautoinline
-Mextract
-Minline
-Mnoinline
-Mrecursive
4.1. C++ 和 C 中的自动函数内联
要在 C++ 和 C 中为带有 inline
关键字的静态函数启用自动函数内联,请使用 -Mautoinline
选项(包含在 -fast
中)。使用 -Mnoautoinline
禁用它。
这些 -Mautoinline
子选项允许您确定选择标准,其中 n
大致对应于过程中的行数
- maxsize:
n
自动内联大小为
n
及以下的函数- totalsize:
n
将自动内联限制为总大小
n
4.2. 调用过程内联
要调用过程内联器,请使用 -Minline
选项。如果您未指定内联库,则编译器会在编译任何源文件之前,对编译器命令行中命名的所有源文件执行特殊的预处理。此预处理提取满足内联要求的过程,并将它们放入临时内联库中,以供编译预处理使用。
多个 -Minline
子选项允许您确定要内联的过程的选择标准。这些子选项包括
- except:
func
内联除
func
之外的所有符合条件的过程,func
是源代码中的过程。您可以使用逗号分隔的列表来指定多个过程。- [name:]``func``
内联源代码中名称与
func
匹配的所有过程。您可以使用逗号分隔的列表来指定多个过程。- [maxsize:]``n``
数字选项被假定为大小。大小为
n
或更小的过程将被内联,其中n
大致对应于过程中的行数。如果同时指定了n
和func
,则将内联与给定名称匹配或满足大小要求的过程。- reshape
如果数组形状与调用者中的形状不匹配,则默认情况下不会内联带有数组参数的 Fortran 子程序。使用此选项可覆盖默认值。
- smallsize:
n
始终内联大小小于
n
的过程,而与其他大小限制无关。- totalsize:
n
totalsize:
n
- 当过程的内联总大小达到指定的
n
时停止在该过程中内联。 [lib:]``file.ext``
指示内联器内联库文件
file.ext
中的过程。如果未指定内联库,则从在提取预处理期间创建的临时库中提取过程。提示
使用 -Mextract
选项创建库文件。
如果您同时指定过程名称和 maxsize n,则编译器会内联与过程名称匹配或具有 n 个或更少语句的过程。
如果名称在没有关键字的情况下使用,则带句点的名称被假定为内联库,而不带句点的名称被假定为过程名称。如果数字在没有关键字的情况下使用,则该数字被假定为大小。
可以使用 -Mnoinline
禁用内联。
$ nvfortran -Minline=maxsize:100 myprog.f
在以下示例中,编译器内联源文件 myprog.f
中语句数少于大约 100 个的程序,并将可执行代码写入默认输出文件 a.out
。
4.3. 使用内联库
如果您使用 -Minline
选项在命令行中指定一个或多个内联库,则编译器不会执行初始提取预处理。编译器从指定的内联库中选择要内联的函数。如果您还指定了大小或函数名称,则将选择内联库中满足选择标准的所有函数,以便在源代码文本中调用它们的点进行内联扩展。
如果您没有为 -Minline
选项指定函数名称或大小限制,则编译器会尝试内联内联库中与源代码文本中的函数匹配的每个函数。
$ nvfortran -Minline=name:proc,lib:lib.il myprog.f
在以下示例中,编译器内联内联库 lib.il
中的函数 proc
,并将可执行代码写入默认输出文件 a.out
。
$ nvfortran -Minline=proc,lib.il myprog.f
以下命令行与前面的行等效,但以下示例不使用关键字 name:
和 lib:
除外。您通常使用关键字来避免在使用不包含句点的内联库名称时发生名称冲突。否则,在没有关键字的情况下,句点会通知编译器命令行上的文件是内联库。
4.4. 创建内联库
您可以使用 -Mextract
命令行选项创建或更新内联库。如果您没有使用 -Mextract
选项指定选择标准,则编译器会尝试提取所有过程。
多个
-Mextract
选项允许您确定用于创建或更新内联库的选择标准。这些选择标准包括func
- 提取过程
func
。您可以使用逗号分隔的列表来指定多个过程。 [name:]
func
- 提取名称与
func
匹配的过程,func
是源代码文本中的过程。 [size:]
n
注意
将提取过程的大小限制为语句计数小于或等于
n
(指定的大小)的过程。- 大小 n 可能不完全等于所选过程中的语句数;大小参数仅是一个粗略的衡量标准。
[lib:]
ext.lib
将提取的信息存储在库目录
ext.lib
中。
如果未指定内联库,则将过程提取到在提取预处理期间创建的临时库中,以在编译阶段使用。
$ nvfortran -Mextract=lib:lib.il myfunc.f
当您使用 -Mextract
选项时,仅执行提取阶段;不执行编译和链接阶段。提取预处理的输出是一个可用于内联的过程库。此输出放置在使用 -o
文件名规范在命令行上指定的内联库文件中。如果库文件存在,则新信息将附加到其中。如果文件不存在,则会创建该文件。您可以使用类似于以下的命令
您可以将 -Minline
选项与 -Mextract
选项一起使用。在这种情况下,提取的过程库可以将其他过程内联到库中。同时使用这两个选项使您能够获得多个级别的内联。在这种情况下,如果您没有使用 -Minline
选项指定库,则内联过程由两个提取预处理组成。第一个预处理是由 -Minline
选项隐含的隐藏预处理,在此期间,编译器提取过程并将它们放入临时库中。第二个预处理使用第一个预处理的结果,但将其结果放入您使用 -o
选项指定的库中。
4.4.1. 使用内联库
内联库实现为目录,库中的每个内联函数都存储为文件,使用可内联函数的编码形式。
内联库目录中名为 TOC
的特殊文件充当内联库的目录。这是一个可打印的 ASCII 文件,您可以检查它以查找有关库内容的信息,例如函数的名称和大小、从中提取它们的源文件、创建条目的提取器的版本号等等。
可以使用普通的系统命令来操作库及其元素。
内联库可以复制或重命名。
库的元素可以删除或从一个库复制到另一个库。
可以使用 ls 或 dir 命令来确定库条目的上次更改日期。
4.4.2. 依赖关系
当使用 NVIDIA HPC 编译器之一创建或更新库时,库目录的上次更改日期会更新。这允许将库列为 makefile 中的依赖项,并确保在库更改时执行必要的编译。
4.4.3. 更新内联库 – Makefiles
如果您使用内联库,则必须确保它们与内联到其中的源文件保持最新。确保内联库更新的一种方法是将它们包含在 makefile 中。
以下示例中的 makefile 片段假定文件 utils.f
包含文件 parser.f
和 alloc.f
中使用的许多小型函数。
makefile 的这一部分
维护内联库
utils.il
。每当您更改
utils.f
或其使用的包含文件之一时,都会更新库。
每当您更新库时,都会编译 parser.f
和 alloc.f
。
SRC = mydir
FC = nvfortran
FFLAGS = -O2
main.o: $(SRC)/main.f $(SRC)/global.h
$(FC) $(FFLAGS) -c $(SRC)/main.f
utils.o: $(SRC)/utils.f $(SRC)/global.h $(SRC)/utils.h
$(FC) $(FFLAGS) -c $(SRC)/utils.f
utils.il: $(SRC)/utils.f $(SRC)/global.h $(SRC)/utils.h
$(FC) $(FFLAGS) -Mextract=15 -o utils.il $(SRC)/utils.f
parser.o: $(SRC)/parser.f $(SRC)/global.h utils.il
$(FC) $(FFLAGS) -Minline=utils.il -c $(SRC)/parser.f
alloc.o: $(SRC)/alloc.f $(SRC)/global.h utils.il
$(FC) $(FFLAGS) -Minline=utils.il -c $(SRC)/alloc.f
myprog: main.o utils.o parser.o alloc.o
$(FC) -o myprog main.o utils.o parser.o alloc.o
示例 Makefile
4.5. 内联期间的错误检测
$ nvfortran -Minline=mylib.il -Minfo=inline myext.f
当您调用内联器时,可以指定 -Minfo=inline
选项来请求编译器提供内联信息。例如
4.6. 示例
$ nvfortran dhry.f -Minline=proc7
假设程序 dhry
由单个源文件 dhry.f
组成。以下命令行为 dhry
构建一个可执行文件,其中 proc7 在其调用的任何位置内联
注意
以下命令行为 dhry
构建一个可执行文件,其中 proc7 加上任何语句数约为 10 个或更少的函数都被内联(仅一级)。
$ nvfortran dhry.f -Mextract=lib:temp.il
$ nvfortran dhry.f -Minline=10,proc7,temp.il
只有在提取阶段预先放置在内联库 temp.il
中的情况下,才会内联指定的函数。
$ nvfortran dhry.f -Minline=maxsize:10
使用相同的源文件 dhry.f
,以下示例为 dhry
构建一个可执行文件,其中大约十个或更少语句的所有函数都被内联。执行两级内联。这意味着如果函数 A 调用函数 B,而 B 调用 C,并且 B 和 C 都是可内联的,则内联到 A 中的 B 版本将已将 C 内联到其中。
4.7. 内联限制
以下 Fortran 子程序无法提取
Main 或 BLOCK DATA 程序。
包含备用返回、赋值 GO TO、DATA、SAVE 或 EQUIVALENCE 语句的子程序。
包含 FORMAT 语句的子程序。
包含多个入口点的子程序。
如果满足以下任何条件,则不会内联 Fortran 子程序
它在语句函数中被引用。
参数不匹配;换句话说,实际参数和形参的数量和类型(大小)必须相等。
存在名称冲突,例如在提取的子程序中调用子例程
xyz
,而在调用者中存在名为xyz
的变量。
以下类型的 C 和 C++ 函数无法内联
接受可变数量参数的函数
某些 C/C++ 函数只能内联到包含其定义的文件中
静态函数
调用静态函数的函数
引用静态变量的函数
5. 使用 GPU
NVIDIA GPU 可以用作加速器,CPU 可以将数据和可执行内核卸载到该加速器,以执行计算密集型运算。本节概述了使用 NVIDIA HPC 编译器对 NVIDIA GPU 进行编程的选项,并涵盖了在使用一个或多个 GPU 编程模型时影响 GPU 编程的主题。
5.1. 概述
借助 NVIDIA HPC 编译器,您可以使用某些标准语言结构、OpenACC 指令、OpenMP 指令或 CUDA Fortran 语言扩展来对 NVIDIA GPU 进行编程。使用标准语言结构或指令进行 GPU 编程,使您无需显式初始化 GPU、管理主机和 GPU 之间的数据或程序传输,或启动和关闭 GPU,即可创建高级 GPU 加速程序。相反,所有这些细节都隐含在编程模型中,并由 NVIDIA HPC SDK Fortran、C++ 和 C 编译器管理。使用 CUDA 扩展进行 GPU 编程,使您可以访问所有 NVIDIA GPU 功能,并完全控制数据管理以及计算密集型循环和内核的卸载。
NVC++ 编译器支持在 -stdpar
编译器选项的控制下,将 C++17 并行算法调用的自动卸载到 NVIDIA GPU。有关使用此功能的详细信息,请参阅博客文章《使用 GPU 加速标准 C++》。NVFORTRAN 编译器支持将某些 Fortran 数组内部函数和数组语法模式(包括使用 Volta 和 Ampere 架构 Tensor Core 用于适当的内部函数)自动卸载到 NVIDIA GPU。有关使用此功能的详细信息,请参阅博客文章《将 Tensor Core 引入标准 Fortran》。
NVFORTRAN 编译器支持 Fortran 中的 CUDA 编程。有关如何使用 CUDA Fortran 的完整详细信息,请参阅《NVIDIA CUDA Fortran 编程指南》。NVCC 编译器支持 C 和 C++ 中的 CUDA 编程,并结合系统上的主机 C++ 编译器。有关如何使用 NVCC 和 CUDA C++ 的介绍和概述,请参阅《CUDA C++ 编程指南》。
NVFORTRAN、NVC++ 和 NVC 编译器都支持使用 OpenACC 对 NVIDIA GPU 进行基于指令的编程。OpenACC 是一种加速器编程模型,可在操作系统以及各种主机 CPU 和加速器类型(包括 NVIDIA GPU 和多核 CPU)之间移植。OpenACC 指令允许程序员使用符合标准的 Fortran、C++ 或 C 逐步将应用程序迁移到加速器目标,这些代码仍然完全可移植到其他编译器和系统。它允许程序员扩充编译器可用的信息,包括加速器区域本地数据的规范、循环到加速器映射的指导以及类似的性能相关细节。
NVFORTRAN、NVC++ 和 NVC 编译器支持 OpenMP 应用程序编程接口的子集,用于 CPU 和 GPU。为 GPU 正确构建的 OpenMP 应用程序(意味着它们暴露了大规模并行性,并且在 GPU 端代码段中几乎没有或没有同步)应该能够编译和执行,其性能与同等的 OpenACC 相当或接近。对于 GPU 结构不良的代码可能性能较差,但应能正确执行。
在用户指导的加速器编程中,用户指定要针对卸载到加速器的主机程序区域。用户程序的大部分以及包含目标加速器不支持的构造的区域在主机上执行。
5.2. 术语
清晰且一致的术语对于描述任何编程模型都很重要。本节提供了有效使用本节和相关编程模型所需的术语的定义。
- 加速器
一种并行处理器,例如 GPU 或以多核模式运行的 CPU,CPU 可以将数据和可执行内核卸载到该处理器,以执行计算密集型运算。
- 计算强度
对于给定的循环、区域或程序单元,计算强度是指对计算数据执行的算术运算次数与在存储器层次结构的两个级别之间移动该数据所需的存储器传输次数之比。
- 计算区域
由计算构造定义的结构化块。计算构造 是一个包含循环的结构化块,这些循环被编译用于加速器。计算区域可能需要在区域入口处分配设备内存并将数据从主机复制到设备,并在区域出口处将数据从设备复制到主机内存并释放设备内存。计算构造的动态范围(包括从构造内部调用的过程中的任何代码)是计算区域。在此版本中,计算区域可能不包含其他计算区域或数据区域。
- 构造
由程序员标识或由语言隐式定义的结构化块。当程序执行到达构造的开始和结束时,可能会发生某些操作,例如设备内存分配或主机和设备内存之间的数据移动。计算构造中的循环旨在在加速器上执行。构造的动态范围(包括从构造内部调用的过程中的任何代码)称为区域。
- CUDA
代表 Compute Unified Device Architecture(计算统一设备架构);CUDA C++ 和 Fortran 语言扩展和 API 调用可用于显式控制和编程 NVIDIA GPU。
- 数据区域
由数据构造定义的区域,或包含指令的函数或子例程的隐式数据区域。数据区域通常需要在入口处分配设备内存并将数据从主机复制到设备内存,并在出口处将数据从设备复制到主机内存并释放设备内存。数据区域可以包含其他数据区域和计算区域。
- 设备
对任何类型加速器的通用引用。
- 设备内存
连接到加速器的内存,该内存与主机内存物理隔离。
- 指令
在 C 中,指 #pragma,或在 Fortran 中,指由编译器解释的特殊格式的注释语句,用于扩充有关程序行为的信息或指定程序的行为。
- DMA
直接内存访问,一种在物理隔离的内存之间移动数据的方法;这通常由 DMA 引擎执行,该引擎与主机 CPU 分离,可以访问主机物理内存以及 IO 设备或 GPU 物理内存。
- GPU
图形处理单元;一种加速器设备。
- 主机
在本上下文中具有连接的加速器设备的主 CPU。主机 CPU 控制程序区域以及加载到设备并在设备上执行的数据。
- 循环迭代计数
特定循环执行的次数。
- 私有数据
关于迭代循环,指仅在特定循环迭代期间使用的数据。关于更通用的代码区域,指在该区域内使用,但在该区域之前未初始化,并在该区域之后的任何使用之前重新初始化的数据。
- 区域
构造的动态范围,包括从构造内部调用的任何过程。
- 结构化块
在 C++ 或 C 中,指一个可执行语句(可能是复合语句),顶部有一个入口,底部有一个出口。在 Fortran 中,指一个可执行语句块,顶部有一个入口,底部有一个出口。
- 向量运算
应用于数组每个元素的单个运算或运算序列。
- 可见设备副本
在设备内存中分配的变量、数组或子数组的副本,对于正在编译的程序单元是可见的。
5.3. 执行模型
NVIDIA HPC 编译器的目标执行模型是主机导向的执行,带有连接的加速器设备,例如 GPU。用户应用程序的大部分在主机上执行。计算密集型区域在主机的控制下卸载到加速器设备。加速器设备执行内核,根据加速器硬件的不同,内核可以像紧密嵌套的循环一样简单,也可以像子例程一样复杂。
5.3.1. 主机函数
即使在面向加速器的区域中,主机也必须协调执行;它
在加速器设备上分配内存
启动数据传输
将内核代码发送到加速器
传递内核参数
将内核排队
等待完成
将结果传输回主机
释放内存
注意
在大多数情况下,主机可以将一系列内核排队,以便在设备上一个接一个地执行。
5.4. 内存模型
仅主机 程序和 主机+加速器 程序之间最显著的区别在于,加速器上的内存可以与主机内存完全分离,这在许多 GPU 上都是如此。例如
主机无法直接读取或写入加速器内存,因为它未映射到主机的虚拟内存空间中。
主机内存和加速器内存之间的所有数据移动都必须由主机通过运行时库调用来执行,这些调用显式地在单独的内存之间移动数据。
一般来说,编译器假设加速器可以直接读取或写入主机内存是无效的。这在 OpenACC 2.7 和 OpenMP 5.0 规范中得到了明确定义。
最新的 GPU 系统为 CPU 和 GPU 之间的一些或所有内存区域提供了统一的单地址空间,如下面的 托管和统一内存模式 小节中详述。在这些系统中,可以从主机和加速器子程序访问数据,而无需显式的数据移动。
NVIDIA HPC 编译器支持以下系统内存模式
内存模式 |
描述 |
编译器标志 |
---|---|---|
分离 |
在主机和加速器程序中访问的所有数据都位于单独的内存(CPU 和 GPU)中。应用程序中的数据需要在 CPU 和 GPU 内存之间物理移动,可以通过添加显式注释,或者依靠编译器检测和迁移数据。 |
|
托管 |
动态分配的主机数据放置在 CUDA 托管内存中,CUDA 托管内存是主机和加速器程序之间的统一单地址空间,因此可以在设备上访问,而无需显式的数据移动。所有其他数据(主机、堆栈或全局数据)仍保留在单独的内存中。 |
|
统一 |
所有主机数据都放置在主机和加速器子程序之间的统一单地址空间中;不需要显式的数据移动。此模式适用于具有完整 CUDA 统一内存功能的目标,并且可以利用 CUDA 托管内存进行动态分配。 |
|
如果未通过传递上述 -gpu=mem:*
选项之一显式选择内存模式,则编译器将选择默认内存模式。Stdpar 的默认内存模式在 使用 Stdpar 中进行了解释。当未启用 Stdpar 时,默认内存模式为分离内存。内存模式在每种编程语言中可能具有特定的语义,并且编译器有时可以隐式地确定所需的数据移动。更多详细信息可以在每个编程模型的子节中找到。
以下选项 -gpu=[no]managed
、-gpu=[no]unified
和 -gpu=pinned
已弃用,但仍被接受。有关当前和已弃用的内存特定标志之间的兼容性,请参阅 选择编译器内存模式的命令行选项。
编译器隐式定义了以下与它编译的内存模式相对应的宏
当代码针对分离内存模式编译时,编译器定义
__NVCOMPILER_GPU_SEPARATE_MEM
宏。当代码针对托管内存模式编译时,编译器定义
__NVCOMPILER_GPU_MANAGED_MEM
宏。当代码针对统一内存模式编译时,编译器定义
__NVCOMPILER_GPU_UNIFIED_MEM
宏。如果使用了 CUDA 托管内存,编译器还会额外定义__NVCOMPILER_GPU_MANAGED_MEM
。
当二进制文件针对一种内存模式编译时,它可能需要在具有特定内存功能的系统上运行,如下所示
针对分离内存模式编译的应用程序可以在任何 CUDA 平台上运行。
针对托管内存模式编译的应用程序必须在具有 CUDA 托管内存或完整 CUDA 统一内存功能的平台上运行。
针对统一内存模式编译的应用程序必须在具有完整 CUDA 统一内存的平台上运行。
注意
在加速器子程序中分配的内存无法从主机访问或释放。
5.4.1. 分离的主机和加速器内存注意事项
程序员必须意识到潜在的独立内存,原因有很多,包括但不限于
主机内存和加速器内存之间的内存带宽决定了有效加速给定代码区域所需的计算强度。
加速器内存的有限大小可能会阻止卸载在大量数据上运行的代码区域。
5.4.1.1. 加速器内存
在加速器端,当前的 GPU 实现弱内存模型。特别是,除非线程仅在同步级别并行,并且内存操作被显式屏障分隔,否则它们不支持线程之间的内存一致性。否则,如果一个线程更新一个内存位置,而另一个线程读取相同的位置,或者两个线程将一个值存储到相同的位置,则硬件不保证结果。虽然运行此类程序的结果可能不一致,但说结果不正确是不准确的。根据定义,此类程序被定义为错误的。虽然编译器可以检测到这种性质的一些潜在错误,但仍然有可能编写一个产生不一致数值结果的加速器区域。
加速器子程序中的堆栈数据是按线程分配的。一个线程的堆栈数据不能被其他线程访问。
5.4.1.2. 暂存内存缓冲区
即使选择的编程模型(例如,OpenACC)声明内存传输相对于主机是异步的,加速器和主机之间的内存传输也可能并非总是相对于主机异步的。这种限制可能是由于特定的 GPU 和主机内存架构造成的。
为了帮助主机程序在正在进行与加速器的内存传输时继续执行,NVIDIA HPC 编译器运行时维护一个指定的暂存内存区域,也称为固定缓冲区。此内存区域已在 CUDA API 中注册,这使其适用于 GPU 和主机之间的异步内存传输。当启动异步内存传输时,要传输的数据将通过固定缓冲区暂存。可以对相同数据发出多个异步操作 - 在这种情况下,运行时系统将对暂存内存缓冲区中的数据进行操作,而不是对原始主机内存进行操作。当主机程序发出显式或隐式同步请求时,数据将从固定缓冲区透明地移动到其目标位置,这对应用程序是透明的。
运行时有权根据主机和 GPU 内存架构启用或禁用固定缓冲区。此外,固定缓冲区的大小由运行时系统适当地确定。用户可以使用应用程序启动时的环境变量来控制其中一些决策。请参阅 控制设备内存管理的环境变量 以了解更多信息。
5.4.1.3. 缓存管理
一些当前的 GPU 具有软件管理的缓存,一些具有硬件管理的缓存,大多数具有硬件缓存,这些硬件缓存只能在某些情况下使用,并且仅限于只读数据。在 CUDA 等低级编程模型中,管理这些缓存取决于程序员。OpenACC 编程模型提供了程序员可以使用的指令作为编译器进行缓存管理的提示。
5.4.1.4. 控制设备内存管理的环境变量
本节总结了 NVIDIA HPC 编译器用于控制设备内存管理的环境变量。
下表包含当前支持的环境变量,并提供了每个变量的简要说明。
环境变量 |
用途 |
---|---|
NVCOMPILER_ACC_BUFFERSIZE |
对于 NVIDIA CUDA 设备,此变量定义了用于在主机和设备之间传输数据的固定缓冲区的大小。 |
NVCOMPILER_ACC_CUDA_CTX_SCHED |
对于 NVIDIA CUDA 设备,设置创建新 CUDA 上下文时要使用的标志。默认情况下,使用 |
NVCOMPILER_ACC_CUDA_HEAPSIZE |
对于 NVIDIA CUDA 设备,设置在设备上调用 |
NVCOMPILER_ACC_CUDA_MAX_L2_FETCH_GRANULARITY |
对于 NVIDIA CUDA 设备,设置 L2 缓存最大获取粒度大小(以字节为单位)。正确的值是介于 0 和 128 之间的整数。 |
NVCOMPILER_ACC_CUDA_MEMALLOCASYNC |
对于 NVIDIA CUDA 设备,当设置为非零整数值时,启用从默认 CUDA 内存池进行 CUDA 异步内存分配,如 CUDA 工具包文档 中所述。默认情况下,使用内部 NVIDIA HPC 运行时内存池。 |
NVCOMPILER_ACC_CUDA_MEMALLOCASYNC_POOLSIZE |
对于 NVIDIA CUDA 设备,如果 |
NVCOMPILER_ACC_CUDA_NOCOPY |
禁用在主机和 NVIDIA CUDA 设备之间传输用户数据时使用固定缓冲区。当此变量设置为非零整数值时,用户数据将直接传输,绕过固定缓冲区。当此设置生效时,此类数据传输的异步执行可能会受到限制。 |
NVCOMPILER_ACC_CUDA_PIN |
对于 NVIDIA CUDA 设备,在数据指令处启用主机内存固定。当主机内存被固定时,与设备之间的数据传输可以是异步的,这可能会提高程序性能。非零整数值启用此机制。值 |
NVCOMPILER_ACC_CUDA_PINSIZE |
对于 NVIDIA CUDA 设备,设置主机内存固定粒度。如果使用 |
NVCOMPILER_ACC_CUDA_PRINTFIFOSIZE |
对于 NVIDIA CUDA 设备,设置设备上格式化输出调用的缓冲区大小。特别是,它控制 C 函数 |
NVCOMPILER_ACC_CUDA_STACKSIZE |
对于 NVIDIA CUDA 设备,设置设备线程的堆栈大小限制。 |
NVCOMPILER_ACC_DEV_MEMORY |
对于 NVIDIA CUDA 设备,当设置为有效的非零大小值时,启用设备内存池并设置其大小。默认情况下,不使用设备内存池。 |
NVCOMPILER_ACC_MEM_MANAGE |
对于 NVIDIA CUDA 设备,当设置为整数值 0 时,禁用内部设备内存管理器。默认情况下,设备内存管理器已启用。它维护已释放的设备内存块列表,以尝试有效地将它们重用于将来的分配。 |
5.4.2. 托管和统一内存模式
NVIDIA HPC 编译器支持与 CUDA 统一内存 的互操作性。此功能在 x86-64 和 Arm 服务器编译器中可用。统一内存为 CPU 和 GPU 提供单地址空间;CPU 和 GPU 内存之间的数据移动由 NVIDIA CUDA 驱动程序隐式处理。
每当在 CPU 或 GPU 上访问数据时,如果上次访问不是在同一设备上,则可能会触发数据传输。在某些情况下,可能会发生页面抖动并影响性能。有关 CUDA 统一内存的介绍,请访问 Parallel Forall。
5.4.2.1. 托管内存模式
在托管内存模式下,程序单元中的所有 Fortran、C++ 和 C 显式分配语句(例如,allocate
、new
和 malloc
分别对应)都将替换为等效的 CUDA 托管数据分配调用,这些调用将数据放置在 CUDA 托管内存中。结果是,不需要 OpenACC 和 OpenMP 数据子句和指令来管理数据移动。它们本质上被忽略并且可以省略。对于 Stdpar,这是最小的必需内存模式,因为没有用于并行区域中使用的数据的特定注释。
要启用托管内存模式,请将选项 -gpu=mem:managed
添加到编译器和链接器命令行。
当程序分配托管内存时,它会分配主机固定内存以及设备内存,因此使得分配和释放操作稍微昂贵,而数据传输稍微快一些。内存池分配器用于减轻分配和释放操作的开销。更多详细信息可以在 内存池分配器 中找到。
托管内存模式具有以下限制
托管内存的使用仅适用于动态分配的数据。
给定一个可分配的聚合,其成员指向本地、全局或静态数据,使用
-gpu=mem:managed
编译并尝试从计算内核通过该指针访问内存将导致运行时失败。不支持 C++ 虚函数。
必须使用
-gpu=mem:managed
编译器选项来编译在其中分配变量(从 GPU 访问)的文件,即使源文件中没有要加速到 GPU 的代码。当链接多个翻译单元时,应用程序必须确保使用与其分配方案相对应的方案来释放所有数据。例如,如果数据在托管内存中分配,则必须使用 CUDA API 调用来执行托管内存的释放。更多详细信息和额外的编译器支持在 拦截释放 中详细介绍。
当与 NVIDIA Kepler GPU 一起使用时,托管内存模式具有以下附加限制
Kepler GPU 上的数据移动通过快速固定的异步数据传输实现;但是,从程序的角度来看,传输是同步的。
当在具有 Kepler GPU 的系统上使用
-gpu=mem:managed
时,NVIDIA HPC 编译器运行时强制执行内核的同步执行。这种情况可能会由于额外的同步以及 CPU 和 GPU 之间重叠的减少而导致性能降低。托管内存的总量限制为 Kepler GPU 上可用的设备内存量。
内存分配/释放自动更改为托管内存
当编译器使用 CUDA 托管内存功能(使用 -gpu=mem:managed
或 -gpu=mem:unified
)时,以下显式分配/释放会自动更改为 cudaMallocManaged
/cudaFree
类型的分配/释放
对于 C++
所有对分配或释放内存的全局
operator new
和operator delete
的调用,例如operator new(std::size_t size) operator new(std::size_t size, const std::nothrow_t ¬hrow_value) operator new(std::size_t size, std::align_val_t align) operator new(std::size_t size, std::align_val_t align, const std::nothrow_t ¬hrow_value) operator delete(void *p) operator delete(void *p, std::size_t size) operator delete(void *p, std::align_val_t align) operator delete(void *p, std::size_t size, std::align_val_t align) operator delete(void *p, const std::nothrow_t ¬hrow_value) operator delete(void *p, std::align_val_t align, const std::nothrow_t ¬hrow_value)
上述重载的所有数组形式。
所有对
malloc
/free
函数的调用。
对于 C 语言:所有对
malloc
/free
函数的调用。对于 Fortran 语言
所有自动数组的分配。
所有带有可分配数组或指针变量的
allocate
/deallocate
语句。
5.4.2.2. 统一内存模式
在统一内存模式下,程序的要求相比托管内存模式进一步放宽。具体而言,不仅动态分配的系统内存可以在 GPU 上访问,全局内存和本地内存也可以访问。
要启用此功能,请将选项 -gpu=mem:unified
添加到编译器和链接器命令行。
使用 -gpu=mem:unified
编译的程序必须在支持完整 CUDA 统一内存功能的系统上运行。目前,完整的 CUDA 统一内存支持 NVIDIA Grace Hopper Superchip 系统和在 Linux 内核中启用异构内存管理 (HMM) 功能的 Linux x86-64 系统。有关这些平台的详细信息,请参阅 NVIDIA 网站上的以下博客文章:使用 NVIDIA Grace Hopper Superchip 简化 HPC 的 GPU 编程 和 使用异构内存管理简化 GPU 应用程序开发。
在统一内存模式下,编译器假定任何系统内存都可以在 GPU 上访问。即便如此,当编译器认为显式数据分配对程序性能有利时,它可能会为显式数据分配生成托管内存分配。如果您想强制或禁止为动态分配使用托管内存,请将 -gpu=mem:unified:[no]managedalloc
传递给编译和链接。
统一内存模式具有以下限制:
OpenACC、OpenMP 和 Stdpar Fortran 的统一内存支持不能混合搭配;所有包含 OpenACC/OpenMP 指令或 Fortran
DO CONCURRENT
构造的目标文件都必须使用-gpu=mem:unified
编译和链接,以确保正确执行。不支持 C++ 虚函数。
过渡到统一内存模式
过渡到支持统一内存模式的应用程序可以使用 -gpu=mem:unified
重新编译,而无需进行任何代码修改。
程序员应该意识到,在统一内存模式下,整个程序状态本质上在 CPU 和 GPU 之间共享。这意味着,在 GPU 上对程序变量所做的修改在 CPU 上是可见的。也就是说,即使程序包含相应的指令,GPU 也不会在数据副本上操作,而是直接在系统内存中的数据上操作。为了理解这个概念的重要性,请考虑以下 OpenACC C 程序:
int x[N];
void foo() {
#pragma acc enter data create(x[0:N])
#pragma acc parallel loop
for (int i = 0; i < N; i++) {
x[i] = i;
}
}
当在分离内存模式下编译时,在 foo()
函数中,数组 x
的副本在 GPU 内存中创建,并按照 loop
构造中的写入方式进行初始化。但是,当添加 -gpu=mem:unified
时,编译器会忽略 acc enter data
构造,并且 loop
构造在系统内存中初始化数组 x
。
另一个需要注意的含义是,GPU 上的异步代码执行可能会导致对程序数据访问的竞争条件。有关为统一内存模式编写应用程序源代码时要避免的代码模式的更多详细信息,请参阅本指南中关于特定编程模型(例如 OpenACC、OpenMP 或 CUDA Fortran)的部分。
5.4.3. 内存池分配器
动态内存分配可以使用 cudaMallocManaged()
完成,该例程的开销高于使用 cudaMalloc()
分配非托管内存。调用 cudaMallocManaged()
的次数越多,对性能的影响就越大。
为了减轻 cudaMallocManaged()
或其他 CUDA 分配 API 调用的开销,在存在 -gpu=mem:managed
、-gpu=mem:separate:pinnedalloc
或 -gpu=mem:unified
编译器选项的情况下,默认启用池分配器。可以使用以下环境变量禁用或修改其行为:
环境变量 |
用途 |
---|---|
NVCOMPILER_ACC_POOL_ALLOC |
禁用池分配器。默认情况下启用池分配器;要禁用它,请将 NVCOMPILER_ACC_POOL_ALLOC 设置为 0。 |
NVCOMPILER_ACC_POOL_SIZE |
设置池的大小。默认大小为 1GB,但可以使用其他大小(即 2GB、100MB、500KB 等)。实际池大小的设置使得该大小是斐波那契数列中与提供的或默认大小相比最接近的较小数字。如有必要,池分配器将添加更多池,但最多达到 NVCOMPILER_ACC_POOL_THRESHOLD 值。 |
NVCOMPILER_ACC_POOL_ALLOC_MAXSIZE |
设置分配的最大大小。默认的最大分配大小为 500MB,但只要大于或等于 16B,就可以使用另一个大小(即 100KB、10MB、250MB 等)。 |
NVCOMPILER_ACC_POOL_ALLOC_MINSIZE |
设置分配块的最小大小。默认大小为 128B,但可以使用其他大小。大小必须大于或等于 16B。 |
NVCOMPILER_ACC_POOL_THRESHOLD |
设置池分配器可以占用的设备总内存的百分比。接受的值为 0 到 100。默认值为 50,对应于设备内存的 50%。 |
注意
请注意,在指定大小时,如果省略单位后缀(B、KB、MB 或 GB),则默认情况下该值以字节为单位设置。
5.4.4. 拦截释放
虽然 NVIDIA HPC 编译器有助于自动使用托管或固定内存,但应用程序必须确保使用与用于分配内存的 API “匹配”的 API 释放内存。例如,如果使用 cudaMallocManaged
进行分配,则必须使用 cudaFree
进行释放;如果使用 cudaMallocHost
进行分配,则必须使用 cudaFreeHost
进行释放。当使用第三方或标准库时,理解此要求尤为重要;这些库可能在没有任何内存模式设置的情况下编译,这会造成库中的释放例程可能与所做的分配不匹配的情况。当使用不匹配的 API 调用释放数据时,应用程序可能会表现出未定义的行为,包括崩溃。为了减轻这个问题,编译器支持一种拦截模式,其中运行时检查对标准释放函数(例如 C 中的 free,C++ 中的 delete 或 Fortran 中的 deallocate)的调用,并且如果未检测到内存是系统分配的,则运行时会将标准释放函数替换为与正在使用的分配方案相对应的释放 API。要激活此拦截模式,请使用 -gpu=interceptdeallocations
编译器标志。默认情况下,对于托管内存分配的情况,Stdpar 中启用拦截。要停用拦截,请使用 -gpu=nointerceptdeallocations
编译器开关。此拦截可能会产生额外的运行时开销。
5.4.5. 选择编译器内存模式的命令行选项
下表将新的内存模型标志映射到其已弃用的等效项。
当前标志 |
已弃用标志 |
简要说明 |
---|---|---|
|
|
托管内存模式 |
|
|
托管内存模式 |
|
|
统一内存模式 |
|
|
统一内存模式,所有动态分配的数据都隐式地位于 CUDA 托管内存中。 |
|
|
统一内存模式,不隐式使用 CUDA 托管内存。 |
|
|
分离内存模式 |
|
|
分离内存模式 |
|
|
分离内存模式 |
|
|
分离内存模式,动态分配的数据隐式地位于 CPU 固定内存中。 |
5.5. 设备代码中的 Fortran 指针
Fortran 指针变量使用指针和描述符实现,其中描述符(通常称为“dope vector”)保存每个维度的数组边界和步幅,以及其他信息,例如每个元素的大小以及指针是否已关联。Fortran 标量指针没有边界信息,但确实具有最小的描述符。在 Fortran 中,引用指针变量始终指的是指针目标。没有语法可以显式引用实现指针变量的指针和描述符。
Fortran 可分配数组和变量的实现方式与指针数组和变量的实现方式非常相似。以下大部分讨论都适用于可分配对象和指针。
在 OpenACC 和 OpenMP 中,当指针变量引用出现在数据子句中时,将分配指针目标或将其移动到设备内存。指针和描述符既不分配也不移动。
当在模块声明部分中声明指针变量并出现在 !$acc declare create()
或 !$omp declare target to()
指令中时,则指针和描述符在设备内存中静态分配。当指针变量出现在数据子句中时,指针目标被分配或复制到设备,并且指针和描述符“附加”到数据的设备副本。如果指针目标已存在于设备内存中,则不会分配或复制新内存,但指针和描述符仍然“附加”,从而使指针在设备内存中有效。在模块声明部分中添加 declare create
的一个重要副作用是,当程序对指针(或可分配对象)执行“allocate”语句时,内存会在 CPU 和设备内存中都分配。这意味着新分配的数据已存在于设备内存中。要从 CPU 获取值到设备内存或返回,您必须使用 update
指令。
当指针变量在 OpenACC 或 OpenMP 计算构造中使用时,编译器会为每个线程创建指针和描述符的私有副本,除非指针变量如上所述在模块中。私有指针和描述符将包含有关指针目标的设备副本的信息。在计算构造中,指针变量的使用方式与在计算构造外部的主机代码中几乎相同。但是,存在一些限制。程序可以对指针执行指针赋值,从而更改指针,但这只会更改该线程的私有指针。计算构造中修改后的指针不会更改主机内存中相应的指针和描述符。
5.6. 在计算内核中调用例程
在使用 Fortran 应用程序时,使用显式接口是一种常见情况。以下是一些在 GPU 编程中需要这样做的情况。
当使用 OpenACC
routine bind
或 OpenMPdeclare variant
时,需要显式接口。Fortran
do concurrent
要求例程是pure
的,这创建了对显式接口的需求。
5.7. 支持的处理器和 GPU
此 NVIDIA HPC 编译器版本支持 x86-64 和 Arm 服务器 CPU。不支持跨不同 CPU 系列的交叉编译,但您可以使用 -tp=<target>
标志(如手册页中所述)来指定系列中的目标处理器。
要指示编译器为 NVIDIA GPU 生成代码,请使用 -acc
标志启用 OpenACC 指令,使用 -mp=gpu
标志启用 OpenMP 指令,使用 -stdpar
标志进行标准语言并行化,以及使用 -cuda
标志进行 CUDA Fortran。使用 -gpu
标志选择 GPU 代码生成的特定选项。然后,您可以在安装了 CUDA 的任何受支持系统上使用生成的代码,该系统具有支持 CUDA 的 GeForce、Quadro 或 Tesla 卡。
有关这些标志与加速器技术的关系的更多信息,请参阅 编译 OpenACC 程序。
有关支持的 CUDA GPU 的完整列表,请参阅 NVIDIA 网站:http://www.nvidia.com/object/cuda_learn_products.html
5.8. CUDA 版本
NVIDIA HPC 编译器使用 NVIDIA CUDA 工具包中的组件来构建在 NVIDIA GPU 上执行的程序。NVIDIA HPC SDK 将 CUDA 工具包组件放入 HPC SDK 安装子目录中;HPC SDK 目前捆绑了两个最新发布的工具包版本。
您可以在 HPC 编译器支持的任何系统上编译用于 NVIDIA GPU 的程序。您将只能在具有 NVIDIA GPU 和已安装 NVIDIA CUDA 驱动程序的系统上运行该程序。NVIDIA HPC SDK 产品不包含 CUDA 设备驱动程序。您必须下载并安装相应的 NVIDIA CUDA 驱动程序。
NVIDIA HPC SDK 实用程序 nvaccelinfo
将驱动程序版本打印在其输出的第一行。您可以使用它来查找系统上安装的 CUDA 驱动程序的版本。
NVIDIA HPC SDK 25.1 包括以下版本的 CUDA 工具包中的组件:
CUDA 11.8
CUDA 12.4
如果您要在没有安装 CUDA 驱动程序的系统上编译用于 GPU 执行的程序,则编译器会根据 DEFCUDAVERSION
变量的值选择要使用的 CUDA 工具包版本,该变量包含在名为 localrc
的文件中,该文件在 HPC SDK 安装期间创建。
如果您要在具有安装 CUDA 驱动程序的系统上编译用于 GPU 执行的程序,则编译器会检测 CUDA 驱动程序的版本,并从 HPC SDK 捆绑的 CUDA 工具包版本中选择适当的 CUDA 工具包版本使用。
编译器在 /opt/nvidia/hpc_sdk/target/25.1/cuda 目录中查找与系统上安装的 CUDA 驱动程序版本匹配的 CUDA 工具包版本。如果找不到完全匹配的版本,编译器将搜索最接近的匹配版本。对于 CUDA 驱动程序版本 11.2 到 11.8,编译器将使用 CUDA 11.8 工具包。对于 CUDA 驱动程序版本 12.0 及更高版本,编译器将使用最新的 CUDA 12.x 工具包。
您可以使用编译器选项更改编译器对 CUDA 工具包版本的默认选择。将 cudaX.Y
子选项添加到 -gpu
,其中 X.Y
表示 CUDA 版本。使用编译器选项会更改编译器一次调用所使用的 CUDA 工具包版本。例如,要使用 CUDA 11.8 工具包编译 OpenACC C 文件,您可以使用:
nvc -acc -gpu=cuda11.8
5.9. 计算能力
编译器可以为 NVIDIA GPU 计算能力 3.5 到 8.6 生成代码。编译器构建一个默认的计算能力列表,该列表与编译中使用的系统上找到的 GPU 支持的计算能力相匹配。如果未检测到 GPU,则编译器会为每个支持的计算能力生成代码。
您可以使用命令行选项或 rcfile
覆盖默认值。
要使用命令行选项更改默认值,请向 -gpu
选项提供逗号分隔的计算能力列表。
要使用 rcfile
更改默认值,请将 DEFCOMPUTECAP
值设置为安装目录的 bin 目录中的 siterc 文件中以空格分隔的计算能力列表。
set DEFCOMPUTECAP=60 70;
或者,如果您没有更改 siterc
文件的权限,您可以将 DEFCOMPUTECAP
定义添加到主目录中的单独的 .mynvrc
文件中。
设备代码的生成可能非常耗时,因此您可能会注意到编译时间随着计算能力的增加而增加。
5.10. PTX JIT 编译
从 HPC SDK 22.9 开始,所有编译器都在可重定位设备代码模式下启用了对 PTX JIT 编译的支持。这意味着使用 -gpu=rdc
构建的应用程序(即,启用了可重定位设备代码,这是默认模式)由于嵌入的 PTX 代码而向前兼容更新的 GPU。当应用程序在比编译时指定的架构更新的 GPU 架构上运行时,嵌入的 PTX 代码会被动态编译。
对 PTX JIT 编译的支持是自动启用的,这意味着您无需更改现有项目的编译器调用命令行。
使用场景
例如,您可以编译针对 Ampere GPU 的应用程序,而无需担心 Hopper GPU 架构。一旦应用程序在 Hopper GPU 上运行,它将无缝使用嵌入的 PTX 代码。
在 CUDA Fortran 中,或者在启用 CUDA 互操作模式的情况下,您可以混合使用使用 CUDA NVCC 编译器编译的目标文件,其中包含 PTX 代码。来自 NVCC 的此 PTX 代码将与 HPC SDK 编译器生成的目标文件中包含的 PTX 代码一起由 JIT 编译器处理。当使用 CUDA NVCC 编译器时,必须使用 NVCC
--relocatable-device-code
true 开关显式启用可重定位设备代码生成,如 CUDA 编译器驱动程序指南 中所述。更多信息可在本指南的 与 CUDA 的互操作性 部分和 CUDA Fortran 编程指南 中找到。
默认情况下,编译器将选择与编译代码的系统上的 GPU 匹配的计算能力。对于将在编译代码的系统上运行的代码,我们建议让编译器设置计算能力。
当默认设置不起作用时,我们建议为应用程序预期运行的一系列计算能力编译应用程序,例如,使用 -gpu=ccall
编译器选项。当在支持这些计算能力之一的系统上运行应用程序时,允许 CUDA 驱动程序次要版本低于编译时使用的 CUDA 工具包版本,如 CUDA 版本 部分所述。
性能考虑因素
PTX JIT 编译在发生时可能会为应用程序带来启动开销。JIT 编译器保留生成的设备代码的缓存副本,这减少了后续运行的开销。有关 JIT 编译器如何工作的详细信息,请参阅 CUDA 编程指南。
已知限制
一般来说,为了使 PTX JIT 编译能够工作,部署系统上安装的 CUDA 驱动程序必须至少是与用于编译应用程序的 CUDA 工具包匹配的版本。此要求比 CUDA 版本 部分中解释的要求更严格。
例如,如该部分所述,当系统中安装的 CUDA 驱动程序至少为 11.2 时,编译器将使用作为 HPC SDK 工具包一部分提供的 CUDA 11.8 工具包。但是,虽然 CUDA 11.2 驱动程序通常足以运行应用程序,但它将无法编译 CUDA 11.8 工具包生成的 PTX 代码。这意味着任何预期使用 PTX JIT 编译的部署系统都必须至少安装 CUDA 11.8 驱动程序。有关 CUDA 驱动程序与 CUDA 工具包的兼容性的更多信息,请参阅 CUDA 兼容性 指南。
当应用程序预期在比编译时指定的更新的 GPU 架构上运行时,我们建议在部署系统上安装与用于构建应用程序的 CUDA 工具包匹配的 CUDA 驱动程序。实现此目的的一种方法是在编译时使用 NVHPC_CUDA_HOME
环境变量来提供特定的 CUDA 工具包。
以下是一些 PTX 版本不兼容性如何诊断和修复的示例。作为一般规则,如果 CUDA 驱动程序由于 PTX 不兼容而无法运行应用程序,则应用程序将终止并显示指示原因的错误消息。OpenACC 和 OpenMP 应用程序在大多数情况下会建议编译器标志来定位当前的 CUDA 安装。
OpenACC
考虑以下程序,我们将为 Volta GPU 编译该程序,并尝试在 Ampere GPU 上运行,系统上安装了 CUDA 11.5
#include <stdio.h>
#define N 1000
int array[N];
int main() {
#pragma acc parallel loop copy(array[0:N])
for(int i = 0; i < N; i++) {
array[i] = 3.0;
}
printf("Success!\n");
}
当我们构建程序时,HPC SDK 将选择作为默认值包含的 CUDA 11.8 工具包。当我们尝试运行时,它会失败,因为使用 11.8 生成的代码无法与 11.5 驱动程序一起工作
$ nvc -acc -gpu=cc70 app.c
$ ./a.out
Accelerator Fatal Error: This file was compiled: -acc=gpu -gpu=cc70
Rebuild this file with -gpu=cc80 to use NVIDIA Tesla GPU 0
File: /tmp/app.c
Function: main:3
Line: 3
从错误消息中可以看出,系统无法在当前系统上执行 Volta GPU 指令。嵌入的 Volta PTX 无法编译,这意味着 CUDA 驱动程序不兼容。修复此问题的一种方法是在编译时使用已安装的 CUDA 11.5 工具包
$ export NVHPC_CUDA_HOME=/usr/local/cuda-11.5
$ nvc -acc -gpu=cc70 app.c
$ ./a.out
Success!
OpenMP
同样,OpenMP 程序将编译但无法运行
#include <stdio.h>
#define N 1000
int array[N];
int main() {
#pragma omp target loop
for(int i = 0; i < N; i++) {
array[i] = 0;
}
printf("Success!\n");
}
$ nvc -mp=gpu -gpu=cc70 app.c
$ ./a.out
Accelerator Fatal Error: Failed to find device function 'nvkernel_main_F1L3_2'! File was compiled with: -gpu=cc70
Rebuild this file with -gpu=cc80 to use NVIDIA Tesla GPU 0
File: /tmp/app.c
Function: main:3
Line: 3
我们还可以通过使 NVHPC_CUDA_HOME
指向匹配的 CUDA 工具包位置来修复它
$ export NVHPC_CUDA_HOME=/usr/local/cuda-11.5
$ nvc -acc -gpu=cc70 app.c
$ ./a.out
Success!
C++
与 OpenACC 和 OpenMP 应用程序在 PTX JIT 遇到 CUDA 驱动程序版本不足时只是简单终止相比,C++ 应用程序在出现 PTX 不兼容时会抛出系统异常
#include <vector>
#include <algorithm>
#include <execution>
#include <iostream>
#include <assert.h>
int main() {
std::vector<int> x(1000, 0);
x[1] = -20;
auto result = std::count(std::execution::par, x.begin(), x.end(), -20);
assert(result == 1);
std::cout << "Success!" << std::endl;
}
$ nvc++ -stdpar -gpu=cc70 app.cpp
$ ./a.out
terminate called after throwing an instance of 'thrust::system::system_error'
what(): after reduction step 1: cudaErrorUnsupportedPtxVersion: the provided PTX was compiled with an unsupported toolchain.
Aborted (core dumped)
异常消息包含对不兼容 PTX 的直接引用,这反过来意味着 CUDA 工具包和 CUDA 驱动程序版本之间不匹配。
我们可以通过设置 NVHPC_CUDA_HOME
类似地修复它
$ export NVHPC_CUDA_HOME=/usr/local/cuda-11.5
$ nvc++ -stdpar -gpu=cc70 app.cpp
$ ./a.out
Success!
6. 使用 OpenACC
本章概述了基于指令的 OpenACC 编程,其中编译器指令用于指定 Fortran、C 和 C++ 程序中要从主机 CPU 卸载到 NVIDIA GPU 的代码区域。有关将 OpenACC 与 NVIDIA GPU 结合使用的完整详细信息,请参阅 OpenACC 入门指南。
6.1. OpenACC 编程模型
随着 GPU 架构在高性能计算中的兴起,程序员希望能够使用熟悉的、高级的编程模型进行编程,该模型既提供高性能,又提供对各种计算架构的可移植性。OpenACC 于 2011 年作为一种编程模型出现,该模型使用高级编译器指令来公开代码中的并行性,并使用并行化编译器为各种并行加速器构建代码。
本章不尝试描述 OpenACC 本身。为此,请参阅 OpenACC 网站 www.openacc.org 上的 OpenACC 规范。在这里,我们将讨论 OpenACC 规范与其在 NVIDIA HPC 编译器中的实现之间的差异。
OpenACC 网站上还提供了其他资源来帮助您进行并行编程,包括视频教程、课程材料、代码示例、最佳实践指南等。
6.1.1. 并行级别
OpenACC 支持三个并行级别:
外部 doall(完全并行)循环级别
workgroup 或 threadblock(工作器并行)循环级别
内部 synchronous(SIMD 或向量)循环级别
每个级别可以是二维或三维的多维,但域必须是严格矩形的。synchronous 级别可能未完全使用 SIMD 或向量操作实现,因此支持并且需要在此级别上进行显式同步。在 doall 级别上的并行线程之间不支持同步。
设备端的 OpenACC 执行模型公开了这些并行级别,程序员需要理解例如完全并行循环和可向量化但需要跨迭代同步的循环之间的区别。所有完全并行循环都可以安排用于 doall、workgroup 或 synchronous 并行执行中的任何一种,但根据定义,需要同步的 SIMD 向量循环只能安排用于 synchronous 并行执行。
6.1.2. 启用 OpenACC 指令
NVIDIA HPC 编译器使用 -acc
和 -gpu
命令行选项启用 OpenACC 指令。有关这些选项的更多信息,请参阅 编译 OpenACC 程序。
_OPENACC 宏
_OPENACC
宏名称定义为具有值 yyyymm
,其中 yyyy 是年份,mm 是实现支持的 OpenACC 指令版本的月份指定。例如,2017 年 11 月的版本是 201711。启用 OpenACC 指令后,所有 OpenACC 编译器都会定义此宏。
6.1.3. OpenACC 支持
NVIDIA HPC 编译器实现了 OpenACC 2.7 的大多数功能,如 2018 年 11 月发布的《OpenACC 应用程序编程接口》,版本 2.7,http://www.openacc.org 中定义的那样,但以下 OpenACC 2.7 功能不受支持:
嵌套并行性
declare link
强制执行
cache
子句限制,即对列出变量的所有引用都必须位于正在缓存的区域内reduction
子句中的子数组和复合变量self
子句数据构造上的
default
子句
6.1.4. OpenACC 扩展
NVIDIA Fortran 编译器支持对 loop
构造上的 collapse
子句进行扩展。OpenACC 规范定义了 collapse
collapse(n)
NVIDIA Fortran 支持在 collapse
中使用标识符 force
collapse(force:n)
使用 collapse(force:n)
指示编译器强制折叠非完全嵌套的并行循环。
6.2. 编译 OpenACC 程序
在使用 OpenACC 时,有几个编译器选项特别适用。这些选项包括 -acc
、-gpu
和 -Minfo
。
6.2.1. -[no]acc
启用 [禁用] OpenACC 指令。以下子选项可以在等号 (“=”) 后使用,多个子选项用逗号分隔:
- gpu
OpenACC 指令仅针对 GPU 执行进行编译。
- 主机
host
- 多核
为在主机 CPU 上进行串行执行而编译。
- multicore
为在主机 CPU 上进行并行执行而编译。
- legacy
禁止显示关于已弃用的 NVIDIA 加速器指令的警告。
- [no]autopar
在 acc parallel 中启用 [禁用] 循环自动并行化。默认设置为自动并行化,即启用循环自动并行化。
- [no]routineseq
为 devicee 编译每个例程。默认行为是不将每个例程都视为 seq 指令。
- strict
指示编译器为非 OpenACC 加速器指令发出警告。
- sync
忽略 async 子句
- verystrict
指示编译器对于任何非 OpenACC 加速器指令都失败并报错。
[no]wait
等待每个设备内核完成。默认情况下,内核启动被阻止,除非使用 async 子句。
Default
默认情况下,OpenACC 指令针对 GPU 和顺序 CPU 主机执行进行编译(即,等效于显式设置 -acc=gpu,host
)。
$ nvfortran -acc=verystrict prog.f
Usage
以下命令行请求启用 OpenACC 指令,并为任何非 OpenACC 加速器指令发出错误。
Predefined Macros
以下与编译目标对应的宏是隐式添加的:
__NVCOMPILER_OPENACC_GPU
当 OpenACC 指令针对 GPU 编译时。
__NVCOMPILER_OPENACC_MULTICORE
当 OpenACC 指令针对多核 CPU 编译时。
与 -acc, -cuda, -mp 和 -stdpar 标志结合使用,以指定 GPU 代码生成的选项。以下子选项可以在等号 (“=”) 后使用,多个子选项之间用逗号分隔
- autocompare
在执行时自动比较 CPU 与 GPU 的结果:暗示 redundant
- ccXY
为计算能力为 X.Y 的设备生成代码。可以指定多个计算能力,并且将为每个计算能力生成一个版本。默认情况下,编译器将检测每个已安装 GPU 的计算能力。使用 -help -gpu 查看适用于您安装的有效计算能力。
- ccall
为该平台以及所选或默认 CUDA 工具包支持的所有计算能力生成代码。
- ccall-major
为所有主要支持的计算能力编译。
- ccnative
检测系统上可见的 GPU 并为其生成代码。如果没有可用的设备,将使用与 NVCC 默认值匹配的计算能力。
- cudaX.Y
使用 CUDA X.Y 工具包兼容性(如果已安装)
- [no]debug
在设备代码中启用 [禁用] 调试信息生成
- deepcopy
在 OpenACC 中启用聚合数据结构的完整深度复制;仅限 Fortran
- fastmath
使用来自快速数学库的例程
- [no]flushz
对 GPU 上的浮点计算启用 [禁用] 刷新为零模式
- [no]fma
在 GPU 上生成 [不生成] 融合乘加指令;在
-O1
处为默认值。这可以与全局-M[no]fma
选项结合使用,以显式启用/禁用 CPU 或 GPU 上的 FMA。- [no]implicitsections
将数据子句中的数组元素引用更改 [不更改] 为数组切片。在 C++ 中,
implicitsections
选项会将update device(a[n])
更改为update device(a[0:n])
。在 Fortran 中,它会将enter data copyin(a(n))
更改为enter data copyin(a(:n))
。默认行为noimplicitsections
也可以使用 rcfiles 更改;例如,可以向 siterc 或另一个 rcfile 添加set IMPLICITSECTIONS=0;
。- [no]interceptdeallocations
拦截 [不拦截] 对标准库内存释放(例如
free
)的调用,如果地址在 pinned 或 managed 内存中,则调用相应的 CUDA 内存释放版本,否则调用常规版本。- keep
保留内核文件(.cubin、.ptx、源文件)
- [no]lineinfo
启用 [禁用] GPU 行信息生成
- loadcache:{L1|L2}
选择用于全局内存加载的硬件级别缓存;选项包括默认值
L1
或L2
- [no]managed
在 CUDA Managed 内存中分配 [不分配] 任何动态分配的数据。将
-gpu=nomanaged
与-stdpar
一起使用,以防止该标志在检测到 CUDA Managed 内存功能时隐式使用-gpu=managed
。此选项已弃用。- maxregcount:n
指定要在 GPU 上使用的最大寄存器数;留空表示无限制
- mem:{separate|managed|unified}
为生成的二进制文件选择 GPU 内存模式。这控制要利用的 CUDA 内存功能,例如仅限单独 GPU 内存 (
separate
)、用于动态分配数据的 GPU Managed Memory (managed
) 或系统内存(也称为完全 CUDA Unified Memory)(unified
)。使用 Managed 或 Unified Memory 可以简化编程,无需检测所有要复制到 GPU 上执行的代码区域内和外的数据。- pinned
使用 CUDA Pinned Memory。此选项已弃用。
- ptxinfo
打印 PTX 信息
- [no]rdc
生成 [不生成] 可重定位的设备代码。
- redundant
冗余 CPU/GPU 执行
- safecache
允许缓存指令中使用可变大小的数组切片;编译器假定它们适合 CUDA 共享内存
- sm_XY
为计算能力为 X.Y 的设备生成代码。可以指定多个计算能力,并且将为每个计算能力生成一个版本。默认情况下,编译器将检测每个已安装 GPU 的计算能力。使用 -help -gpu 查看适用于您安装的有效计算能力。
- stacklimit:<l>nostacklimit
设置过程或内核中堆栈变量的限制 (l),单位为 KB。此选项已弃用。
- tripcount:{host|device|[no]check|[no]warn}
确定计算构造中循环的 trip count 值是在主机(默认)还是设备上计算。也可以用于启用 [禁用] 与使用主机与设备 trip count 值相关的运行时检查和编译时警告。
- [no]unified
为 CUDA Unified memory 功能编译 [不编译],其中系统内存可从 GPU 访问。除非通过
-gpu=[no]managed
设置显式行为,否则此模式利用系统内存和 managed 内存来处理动态分配的数据。将-gpu=nounified
与-stdpar
一起使用,以防止该标志在检测到 CUDA Unified memory 功能时隐式使用-gpu=unified
。此选项必须同时出现在编译和链接行中。此选项已弃用。- [no]unroll
启用 [禁用] 自动内部循环展开;在
-O3
处为默认值- zeroinit
使用零初始化分配的设备内存
Default
在以下示例中,编译器为计算能力为 6.0 和 7.0 的 NVIDIA GPU 生成代码。
$ nvfortran -acc -gpu=cc60,cc70 myprog.f
编译器自动调用必要的软件工具来创建内核代码并将内核嵌入到目标文件中。
要链接到适当的 GPU 库,您必须使用 -acc
标志链接 OpenACC 程序,对于 -cuda、-mp 或 -stdpar 也是如此。
DWARF 调试格式
使用 -g
选项在主机和设备上启用完整 DWARF 信息的生成;在没有其他优化标志的情况下,-g
将优化级别设置为零。如果 -O
选项将优化级别提高到 1 或更高,即使指定了 -g
,设备代码中也只会生成 GPU 行信息。要在高于零的优化级别强制为设备代码生成完整 DWARF,请使用 -gpu
的 debug
子选项。相反,要阻止为设备代码生成 dwarf 信息,请使用 -gpu
的 nodebug
子选项。debug
和 nodebug
都可以独立于 -g
使用。
6.3. 用于多核 CPU 的 OpenACC
NVIDIA OpenACC 编译器支持选项 -acc=multicore
,将 OpenACC 程序的目标加速器设置为主机多核 CPU。这将为主机处理器或处理器的核心之间的并行执行编译 OpenACC 计算区域。主机多核 CPU 将被视为共享内存加速器,因此数据子句(copy
、copyin
、copyout
、create
)将被忽略,并且不会执行数据复制。
默认情况下,-acc=multicore
将生成代码,该代码将使用处理器所有可用的核心。如果计算区域在 num_gangs
子句中指定了一个值,则将使用 num_gangs
值和可用核心数中的最小值。在运行时,可以通过将环境变量 ACC_NUM_CORES
设置为常整数值来限制核心数。也可以使用 void acc_set_num_cores(int numcores)
运行时调用来设置核心数。如果 OpenACC 计算构造在词法上出现在 OpenMP 并行构造中,则 OpenACC 计算区域将生成顺序代码。如果 OpenACC 计算区域动态地出现在 OpenMP 区域或另一个 OpenACC 计算区域中,则程序可能会生成比核心数多得多的线程,并可能产生较差的性能。
-acc=multicore
选项与 -acc=host
选项的不同之处在于,-acc=host
为 OpenACC 计算区域生成顺序主机 CPU 代码。
6.4. 带有 CUDA Unified Memory 的 OpenACC
在为支持 CUDA Unified Memory 的目标开发 OpenACC 源代码时,您可以利用简化的编程方法,因为不需要数据子句和指令,无论全部还是部分,具体取决于目标支持的确切内存功能以及使用的编译器选项。
本节中的讨论假定您已熟悉 内存模型 和 Managed 和 Unified Memory 模式 部分中介绍的 Separate、Managed 和 Unified Memory 模式。
在 Managed Memory 模式下,只有动态分配的数据由 CUDA 运行时隐式管理;因此,对于此“managed”数据的移动,不需要 OpenACC 数据子句和指令。数据子句和指令仍然需要处理静态数据(C 静态和外部变量、Fortran 模块、公共块和保存变量)和函数局部数据。
在 Unified Memory 模式下,所有数据都由 CUDA 运行时管理。不再需要显式数据子句和指令来指示哪些数据应驻留在 GPU 内存中。所有变量都可以从 GPU 上执行的 OpenACC 计算区域访问。NVHPC 编译器实现严格遵循 OpenACC 规范中详述的共享内存模式,这意味着 copy
、copyin
、copyout
和 create
子句不会导致任何设备分配或数据传输。device_resident
子句仍然像在离散内存模式中一样被遵守,并且会导致仅可从设备代码访问的数据分配。也可以通过使用 acc_malloc
或 acc_free
API 调用在 Unified Memory 模式下的 OpenACC 程序中分配或释放设备内存。
理解数据移动
在没有可见的数据子句或指令的情况下,当编译器遇到计算构造时,它会尝试确定在 GPU 上正确执行该区域所需的数据。当编译器无法确定需要在设备上访问的数据的大小和形状时,它的行为如下
在 Separate Memory 模式下,编译器会发出错误,请求添加显式数据子句以指定要复制的数据的大小/形状。
在 Managed Memory 模式 (
-gpu=mem:managed
) 下,编译器假定数据分配在 managed 内存中,因此可以从设备访问;如果此假设是错误的,如果数据是全局定义的或位于 CPU 堆栈上,则程序可能会在运行时失败。在 Unified Memory 模式 (
-gpu=mem:unified
) 下,所有数据都可以从设备访问,从而使有关大小和形状的信息变得不必要。
以以下 C 语言示例为例
void set(int* ptr, int i, int j, int dim){
int idx = i * dim + j;
return ptr[idx] = someval(i, j);
}
void fill2d(int* ptr, int dim){
#pragma acc parallel loop
for (int i = 0; i < dim; i++)
for (int j = 0; j < dim; j++)
set(ptr, i, j, dim);
}
在 Separate Memory 模式下,确保此示例正确性的唯一方法是将带有 acc
指令的行更改为如下所示
#pragma acc parallel loop create(ptr[0:dim*dim]) copyout(ptr[0:dim*dim])
此更改显式指示 OpenACC 实现关于并行循环中使用的精确数据段。
在 Unified Memory 模式下,即通过使用 -acc -gpu=mem:unified
进行编译并在具有 unified memory 功能的平台上执行,则不需要 create
和 copyout
子句。
下一个 Fortran 示例说明了如何在 OpenACC 例程中访问全局变量,而无需任何显式注释。
module m
integer :: globmin = 1234
contains
subroutine findmin(a)
!$acc routine seq
integer, intent(in) :: a(:)
integer :: i
do i = 1, size(a)
if (a(i) .lt. globmin) then
globmin = a(i)
endif
end do
end subroutine
end module m
为 Unified Memory 模式编译上面的示例
nvfortran -acc -gpu=mem:unified example.f90
源代码不需要任何 OpenACC 指令即可访问模块变量 globmin
,无论是在从 CPU 和 GPU 调用的例程中读取还是更新其值。此外,对 globmin
的任何访问都将是对来自 CPU 和 GPU 的变量的完全相同的实例进行的;其值会自动同步。在 Separate 或 Managed Memory 模式下,只有通过在源代码中使用 OpenACC declare
和 update
指令的组合才能实现这种行为。
在大多数情况下,迁移为 Separate Memory 模式编写的现有 OpenACC 应用程序应该是一个无缝的过程,无需更改源代码。但是,某些数据访问模式可能会导致在 Unified Memory 模式下应用程序执行期间产生不同的结果。
依赖于在 GPU 内存中拥有单独的数据副本以在 GPU 上执行临时计算(而不维护与 CPU 的数据同步)的应用程序对迁移到 Unified Memory 提出了挑战。
对于以下 Fortran 示例,在最后一个循环之后,变量 c
的值将因示例是否使用 -gpu=mem:unified
编译而异。
b(:) = ...
c = 0
!$acc kernels copyin(b) copyout(a)
!$acc loop
do i = 1, N
b(i) = b(i) * i
end do
!$acc loop
do i = 1, N
a(i) = b(i) + i
end do
!$acc end kernels
do i = 1, N
c = c + a(i) + b(i)
end do
在没有 Unified Memory 的情况下,数组 b
在 OpenACC kernels
区域的开头被复制到 GPU 内存中。然后在 GPU 内存中更新它,并用于计算数组 a
的元素。按照数据子句 copyin(b)
的指示,b
在 kernels
区域结束时不会复制回 CPU 内存,因此其初始值用于计算 c
。使用 -acc -gpu=mem:unified
,第一个循环中 b
的更新值在最后一个循环中自动可见,从而导致 c
在其末尾处的值不同。
异步执行的含义
当在 async
计算区域内访问 CPU-GPU 共享数据而不是在 GPU 上使用独立数据副本时,可能会出现其他复杂性。程序员应特别注意在异步 GPU 代码中访问局部变量。除非在定义局部变量的作用域结束之前显式同步 GPU 代码执行,否则 GPU 可能会访问过时的数据,从而导致未定义的行为。考虑以下 OpenACC C 示例,其中局部数组用于保存 GPU 上的临时数据
void bar() {
int x[N];
#pragma acc enter data create(x[0:N]) async
#pragma acc parallel loop async
for (int i = 0; i < N; i++)
x[i] = i;
...
#pragma acc exit data delete(x[0:N]) async
}
当为 Separate Memory 模式编译时,bar()
函数会在 GPU 内存中创建数组 x
的副本,并按照 loop
构造中的编写方式对其进行初始化。该副本最终会被删除。但是,在 Unified Memory 模式下,编译器会忽略 acc enter data
和 acc exit data
指令,因此在 GPU 上执行的 loop
构造会访问本地 CPU 内存中的数组 x
。此外,由于此示例中的所有构造都是异步的,因此在 GPU 上访问 x
会导致程序未定义的行为,因为变量 x
在 bar()
函数完成后超出作用域。
性能注意事项
在 Unified Memory 模式下,OpenACC 运行时可以利用数据操作信息(例如 create
/delete
或 copyin
/copyout
)通过内存提示 API 将首选数据放置位置传达给 CUDA 运行时,如 NVIDIA 网站上的以下博客文章中所述:使用异构内存管理简化 GPU 应用程序开发。此类操作源于源代码中的显式数据子句或编译器生成的隐式数据移动。这种方法可以最大限度地减少自动数据迁移量,并可能让开发人员微调应用程序性能。对于上面的 C 示例,虽然添加数据子句 create(ptr[0:dim*dim])
和 copyout(ptr[0:dim*dim])
对于 -gpu=mem:unified
变得可选,但在 OpenACC parallel loop
指令中使用它们可能会提高性能。
6.5. OpenACC 错误处理
OpenACC 规范提供了一种机制,允许您拦截在 GPU 上执行期间触发的错误,并在程序退出之前执行特定的例程以作为响应。例如,如果 MPI 进程在 GPU 上分配内存时失败,则应用程序可能希望调用 MPI_Abort
以在程序退出之前关闭所有其他进程。本节介绍如何利用此功能。
要拦截错误,应用程序必须向 OpenACC 运行时提供回调例程。要提供回调,应用程序使用指向回调例程的指针调用 acc_set_error_routine
。
接口如下所示,其中 err_msg
包含错误的描述
typedef void (*exitroutinetype)(char *err_msg);
extern void acc_set_error_routine(exitroutinetype callback_routine);
当 OpenACC 运行时检测到运行时错误时,它将调用 callback_routine
。
注意
此功能与错误恢复不同。如果回调例程返回到应用程序,则行为绝对是未定义的。
让我们通过一个示例更深入地了解此功能。
以下面的 MPI 程序为例,并在两个进程中运行它。进程 0 尝试在 GPU 上分配一个大数组,然后向第二个进程发送消息以确认操作成功。进程 1 等待确认并在收到确认后终止。
#include <stdio.h>
#include <stdlib.h>
#include "mpi.h"
#define N 2147483648
int main(int argc, char **argv)
{
int rank, size;
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);
int ack;
if(rank == 0) {
float *a = (float*) malloc(sizeof(float) * N);
#pragma acc enter data create(a[0:N])
#pragma acc parallel loop independent
for(int i = 0; i < N; i++) {
a[i] = i *0.5;
}
#pragma acc exit data copyout(a[0:N])
printf("I am process %d, I have initialized a vector of size %ld bytes on the GPU. Sending acknowledgment to process 1.", rank, N);
ack = 1;
MPI_Send(&ack, 1, MPI_INT, 1, 0, MPI_COMM_WORLD);
} else if(rank == 1) {
MPI_Recv(&ack, 1, MPI_INT, 0, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE);
printf("I am process %d, I have received the acknowledgment from process 0 that data in the GPU has been initialized.\n", rank, N);
fflush(stdout);
}
// do some more work
MPI_Finalize();
return 0;
}
我们使用以下命令编译程序
$ mpicc -acc -o error_handling_mpi error_handling_mpi.c
如果我们使用两个 MPI 进程运行此程序,则输出将如下所示
$ mpirun -n 2 ./error_handling_mpi
Out of memory allocating -8589934592 bytes of device memory
total/free CUDA memory: 11995578368/11919294464
Present table dump for device[1]:
NVIDIA Tesla GPU 0, compute capability 3.7, threadid=1
...empty...
call to cuMemAlloc returned error 2: Out of memory
-------------------------------------------------------
Primary job terminated normally, but 1 process returned
a non-zero exit code.. Per user-direction, the job has been aborted.
-------------------------------------------------------
--------------------------------------------------------------------------
mpirun detected that one or more processes exited with non-zero status,
thus causing the job to be terminated.
进程 0 在 GPU 上分配内存时失败,并意外终止并出现错误。在这种情况下,mpirun
能够识别出一个进程失败,因此它关闭了剩余的进程并终止了应用程序。像这样简单的双进程程序很容易调试。但在实际应用中,如果有数百或数千个进程,进程过早退出可能会导致应用程序无限期挂起。因此,理想的情况是捕获进程的失败,控制其他进程的终止,并提供有用的错误消息。
我们可以使用 OpenACC 错误处理功能来改进之前的程序,并在 MPI 进程失败的情况下正确终止应用程序。
在以下示例代码中,我们添加了一个错误处理回调例程,如果进程在 GPU 上执行时遇到错误,该例程将关闭其他进程。进程 0 尝试在 GPU 中分配一个大数组,如果操作成功,进程 0 将向进程 1 发送确认。进程 0 调用 OpenACC 函数 acc_set_error_routine
将函数 handle_gpu_errors
设置为错误处理回调例程。此例程打印消息并调用 MPI_Abort
以关闭所有 MPI 进程。如果进程 0 成功在 GPU 上分配数组,进程 1 将收到确认。否则,如果进程 0 失败,它将自行终止并触发对 handle_gpu_errors
的调用。然后,进程 1 由回调例程中执行的代码终止。
#include <stdio.h>
#include <stdlib.h>
#include "mpi.h"
#define N 2147483648
typedef void (*exitroutinetype)(char *err_msg);
extern void acc_set_error_routine(exitroutinetype callback_routine);
void handle_gpu_errors(char *err_msg) {
printf("GPU Error: %s", err_msg);
printf("Exiting...\n\n");
MPI_Abort(MPI_COMM_WORLD, 1);
exit(-1);
}
int main(int argc, char **argv)
{
int rank, size;
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);
int ack;
if(rank == 0) {
float *a = (float*) malloc(sizeof(float) * N);
acc_set_error_routine(&handle_gpu_errors);
#pragma acc enter data create(a[0:N])
#pragma acc parallel loop independent
for(int i = 0; i < N; i++) {
a[i] = i *0.5;
}
#pragma acc exit data copyout(a[0:N])
printf("I am process %d, I have initialized a vector of size %ld bytes on the GPU. Sending acknowledgment to process 1.", rank, N);
fflush(stdout);
ack = 1;
MPI_Send(&ack, 1, MPI_INT, 1, 0, MPI_COMM_WORLD);
} else if(rank == 1) {
MPI_Recv(&ack, 1, MPI_INT, 0, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE);
printf("I am process %d, I have received the acknowledgment from process 0 that data in the GPU has been initialized.\n", rank, N);
fflush(stdout);
}
// more work
MPI_Finalize();
return 0;
}
同样,我们使用以下命令编译程序
$ mpicc -acc -o error_handling_mpi error_handling_mpi.c
我们使用两个 MPI 进程运行程序,并获得以下输出
$ mpirun -n 2 ./error_handling_mpi
Out of memory allocating -8589934592 bytes of device memory
total/free CUDA memory: 11995578368/11919294464
Present table dump for device[1]:
NVIDIA Tesla GPU 0, compute capability 3.7, threadid=1
...empty...
GPU Error: call to cuMemAlloc returned error 2: Out of memory
Exiting...
--------------------------------------------------------------------------
MPI_ABORT was invoked on rank 0 in communicator MPI_COMM_WORLD
with errorcode 1.
这次 GPU 上的错误被应用程序拦截,应用程序使用错误处理回调例程对其进行了管理。在本例中,例程打印了一些有关问题的信息,并调用 MPI_Abort
以终止剩余的进程,并避免应用程序出现任何意外行为。
6.6. OpenACC 和 CUDA Graphs
NVIDIA 提供了一种优化的模型,用于将工作提交到称为 CUDA Graphs 的 GPU 上。图是一系列操作,例如内核启动和其他面向流的任务,这些操作通过其依赖关系连接在一起。图可以定义一次,“捕获”,然后重复启动。这在减少启动延迟和与内核设置相关的其他开销方面具有潜在优势。
有关 CUDA Graphs 和用于图定义、实例化和执行的 CUDA API 的完整说明,请参见 CUDA C 编程指南的第 3 章。在 OpenACC 中,我们目前仅公开最少的操作集,以允许捕获和重放包含 OpenACC 计算区域和数据指令的图。“开始捕获”调用 accx_begin_capture_async()
和“结束捕获”调用 accx_end_capture_async()
之间的代码称为捕获区域。
CUDA 图 API 捕获(或记录)accx_begin_capture_async 和 accx_end_capture_async 之间的所有设备工作。捕获区域中的主机代码将正常执行一次,但设备上实际上不执行任何设备工作。相反,将创建一个图对象,该对象可用于多次重放捕获的工作。
注意
图捕获类似于许多编程语言中的闭包概念,例如 C++ 中的 lambda 函数。用 lambda 函数术语来说,CUDA 图按值捕获所有变量。这意味着所有 FIRSTPRIVATE 标量、数组形状以及那些派生类型、数组和 GPU 上驻留数据的标量地址都被烘焙到图对象中,并且无法更改。当然,指针后面的设备数据可以通过图执行正常更新,也可以通过主机在重放之间更新。
重要的是要了解 CUDA Graph 捕获区域中可以捕获和不能捕获的内容
可以捕获异步数据指令,包括
data create
。OpenACC 运行时将在捕获区域中使用流排序的cudaMallocAsync()
调用来处理需要在数据子句中分配的变量,这是 CUDA Graphs 中允许的 API 调用。可以捕获异步计算区域,最好是 ACC
parallel
区域。对于 ACCkernels
区域,请验证是否在主机上执行了任何工作。主机计算部分无法捕获。可以捕获异步 ACC
update host (self)
和update device
指令。捕获的主机和设备地址在图重放/执行期间必须有效。由于仅捕获和重放设备工作,因此捕获区域内主机和设备之间的任何数据依赖关系都是错误的。例如,在捕获区域内从设备下载数据、在主机上处理数据并将其上传回设备是无效的。
主机代码,甚至包含条件语句的主机代码,都可以出现在捕获区域内。但是请注意,通过主机代码采用的路径将是图捕获的路径,即条件语句在重放期间很可能必须保持一致才能获得正确的结果。更新主机变量的主机代码(例如
i=i+1
)不会在图中捕获,这可能会影响对设备端数组或其他内核参数的正确索引。类似地,在主机代码循环中启动的设备工作可以在 CUDA Graph 中捕获。图不会包含循环的概念,而只是在循环期间提交给设备的设备操作序列。
包含更多计算区域或在设备上运行的其他工作的子例程和函数调用在捕获区域内捕获。必须注意传递给内核的设备数据地址在整个图执行过程中都有效,并且不会基于堆栈地址或类似的东西来来去去。
可以通过捕获两个图来容纳双缓冲或在奇数迭代时输入和偶数迭代时输出的源数组和目标数组之间进行乒乓操作的代码(例如,下面显示的代码片段):每个偶数迭代一个图,每个奇数迭代一个图。
int* src; int* dest; while (err > tolerance) { for (int i = 0; i < N; i++) { dest[i] = foo(src[i]); } err = bar(dest); int* tmp = dest; dest = src; src = tmp; }
许多 CUDA 库调用(例如 cublas 等)可以出现在捕获区域中。库调用的设置(例如创建句柄以及计算和分配工作区需求)应在捕获区域之前完成。
对于每个异步队列,图捕获都是线程安全的。主机线程可以使用不同的异步队列独立捕获图。
当使用
-gpu=tripcount:device
时,循环 trip count 可以在同一捕获图的运行之间变化,只要 trip count 在设备上更新即可。
OpenACC API 非常密切地遵循 CUDA Graph API 的基本部分。主要区别在于 OpenACC 将 cudaGraphInstantiate()
调用作为结束捕获函数的一部分包含在内。
从 Fortran 来看,图类型在 OpenACC 模块中定义
type, bind(c) :: acc_graph_t
type(c_ptr) :: graph
type(c_ptr) :: graph_exec
end type acc_graph_t
这些子例程在 OpenACC 运行时中可用。这里,pGraph 是 type(acc_graph_t),async 只是异步队列值
subroutine accx_async_begin_capture( async )
subroutine accx_async_end_capture( async, pGraph )
subroutine accx_graph_launch( pGraph, async )
subroutine accx_graph_delete( pGraph )
type(c_ptr) function accx_get_graph( pGraph )
type(c_ptr) function accx_get_graph_exec( pGraph )
从 C 来看,图类型在 OpenACC.h 中定义
typedef struct { void *graph; void *graph_exec; } acc_graph_t;
这些 void 函数在 OpenACC 运行时中可用
extern void accx_async_begin_capture(long async);
extern void accx_async_end_capture(long async, acc_graph_t *pgraph);
extern void accx_graph_launch(acc_graph_t *pgraph, long async);
extern void accx_graph_delete(acc_graph_t *pgraph);
extern void *accx_get_graph(acc_graph_t *pgraph);
extern void *accx_get_graph_exec(acc_graph_t *pgraph);
我们将使用一个简单的 Fortran 示例代码,该代码演示了从 OpenACC 使用 CUDA Graphs 所需的一些修改。共轭梯度迭代求解器的原始串行代码
subroutine RunCG(N, A, b, x, tol, max_iter)
implicit none
integer, intent(in) :: N, max_iter
real(WP), intent(in) :: A(N, N), b(N), tol
real(WP), intent(inout) :: x(N)
real(WP) :: alpha, rr0, rr
real(WP), allocatable :: Ax(:), r(:), p(:)
integer :: it, i
allocate(Ax(N), r(N), p(N))
call symmatvec(N, N, A, x, Ax)
do i = 1, N
r(i) = b(i) - Ax(i)
p(i) = r(i)
enddo
rr0 = dot(N, r, r)
do it = 1, max_iter
call symmatvec(N, N, A, p, Ax)
alpha = rr0 / dot(N, p, Ax)
do i = 1, N
x(i) = x(i) + alpha * p(i)
r(i) = r(i) - alpha * Ax(i)
enddo
rr = dot(N, r, r)
print*, "Iteration ", it, " residual: ", sqrt(rr)
if (sqrt(rr) <= tol) then
deallocate(Ax, r, p)
return
endif
do i = 1, N
p(i) = r(i) + (rr / rr0) * p(i)
enddo
rr0 = rr
enddo
deallocate(Ax, r, p)
end subroutine RunCG
对于此练习,我们希望将每次迭代的 do it = 1,max_iter
工作放入 CUDA 图中。第一步是将代码移植到 OpenACC,请记住我们要使用异步队列。我们使用 OpenACC 指令注释 dot 函数,如下所示
function dot(N, x, y) result(r)
integer, intent(in) :: N
real(WP), intent(in) :: x(N), y(N)
integer :: i
real(WP) :: r
r = 0.d0
!$acc parallel loop present(x, y) reduction(+:r) async(1)
do i = 1, N
r = r + x(i) * y(i)
enddo
!$acc wait(1)
end function dot
我们像这样编写对称矩阵乘法
subroutine symmatvec(M, N, AT, x, Ax)
implicit none
integer, intent(in) :: M, N
real(WP), intent(in) :: AT(N, M), x(N)
real(WP), intent(out) :: Ax(M)
integer :: i, j
real(WP) :: s
! Note: Since A is symmetric, we can use the "transpose"
! for better memory access here
!$acc parallel loop gang present(AT, x, Ax) async(1)
do i = 1, M
s = 0.d0
!$acc loop vector reduction(+:s)
do j = 1, N
s = s + AT(j,i) * x(j)
end do
Ax(i) = s
end do
end subroutine
现在我们的共轭梯度求解器的主循环如下所示
do it = 1, max_iter
call symmatvec(N, N, A, p, Ax)
alpha = rr0 / dot(N, p, Ax)
!$acc parallel loop gang vector async(1)
do i = 1, N
x(i) = x(i) + alpha * p(i)
r(i) = r(i) - alpha * Ax(i)
enddo
rr = dot(N, r, r)
print*, "Iteration ", it, " residual: ", sqrt(rr)
if (sqrt(rr) <= tol) exit
!$acc parallel loop gang vector async(1)
do i = 1, N
p(i) = r(i) + (rr / rr0) * p(i)
enddo
rr0 = rr
enddo
步骤 2 是准备在 CUDA Graphs 下运行的代码。主循环中执行了很多主机代码。虽然 dot()
函数在 GPU 上运行,但语句 alpha = rr0 / dot(...)
的其余部分在主机上运行。类似地,第二个 dot()
调用将其值返回给主机。打印语句发生在主机上,残差检查也是如此。最后,此迭代的 rr 值在循环的最后一个语句中移动到 rr0,在主机上。
点积很棘手。我们希望在 GPU 上计算点积,并将结果留在 GPU 上,因此归约变量必须存在于 GPU 上。在这里,我们将函数调用更改为子例程,并删除并行区域外部的初始化
subroutine dot(N, x, y, r)
implicit none
integer, intent(in) :: N
real(WP), intent(in) :: x(N), y(N)
integer :: i
real(WP) :: r
!$acc parallel loop present(x, y, r) reduction(+:r) async(1)
do i = 1, N
r = r + x(i) * y(i)
enddo
end subroutine dot
我们添加一个串行内核来执行 rr0 和 rr 之间的一些交换,以及将用于保存点积归约的标量归零,并将打印和检查移到 GPU 捕获区域之外,并替换为更新主机操作。完成的循环(包括图控制)如下所示
do it = 1, max_iter
if (it .eq. 1) then ! First time capture
call accx_async_begin_capture(1)
call symmatvec(N, N, A, p, Ax)
call dot(N, p, Ax, rden)
!$acc serial async(1)
rr0 = rr
alpha = rr0 / rden
rden = 0.0d0
rr = 0.0d0
!$acc end serial
!$acc parallel loop gang vector async(1)
do i = 1, N
x(i) = x(i) + alpha * p(i)
r(i) = r(i) - alpha * Ax(i)
enddo
call dot(N, r, r, rr)
!$acc update host(rr) async(1)
!$acc parallel loop gang vector async(1)
do i = 1, N
p(i) = r(i) + (rr / rr0) * p(i)
enddo
call accx_async_end_capture(1, graph)
endif
! Always launch, then wait
call accx_graph_launch(graph, 1)
!$acc wait(1)
rra(it) = rr
if (sqrt(rr) <= tol) exit
enddo
步骤 3 是编译、运行和分析结果。除了 -acc=gpu 之外,不需要特殊的编译器选项。运行时,可能会建议您设置 NVCOMPILER_ACC_USE_GRAPH
环境变量。目前,这是正确设置 OpenACC 运行时以进行图捕获所必需的。不遵守上述准则可能会导致错误的答案,这可能很难调试。请参阅以下有关如何使用环境变量来帮助的部分。一个常见的问题是,在图重放期间传递给设备内核的指针每次都相同。确保在没有图捕获的代码中,迭代之间是这种情况。
Nsight Systems 工具对分析 CUDA 图具有非常好的支持。时间线视图将提供有关您是否减少了 GPU 内核之间启动开销间隙的信息。图 1 显示了原始 OpenACC 循环迭代的时间线

图 1. Nsight Systems Report1 时间线
Figure 2 显示了使用 CUDA Graphs 时的迭代时间线。当尺寸 N 小于几千时,启动延迟成为总时间的主要贡献者,在这里我们可以看到大约 2 倍的加速。

图 2. Nsight Systems Report2 时间线
您可以通过向 nsys profile 命令添加 --cuda-graph-trace=node
选项来查看 CUDA Graph 组件的更详细跟踪信息。
上面的循环演示了本节顶部概述的几个指导原则,即捕获计算区域(无论是在顶层还是在子程序单元中)、捕获数据移动以及重构代码区域以最小化或消除捕获区域内的主机代码。以及开始捕获、结束捕获然后启动捕获图的最小 API。
6.7. 主机和设备行程计数选项
-gpu=tripcount
选项控制着计算结构(例如 acc parallel loop
)中循环的行程计数是在主机上还是在设备上计算。NVHPC 编译器的默认行为是使用在主机上计算的值,尽管 OpenACC 规范声明行程计数值应在设备上计算。我们选择保持默认行为不变,以免干扰当前依赖它来保证正确性的现有应用程序。为了确保符合规范,请使用 -gpu=tripcount:device
选项。为了保持默认行为,请使用 -gpu=tripcount:host
或不指定 -gpu=tripcount
选项。
要在编译时发出警告,指示 OpenACC 程序可能正在使用主机的行程计数值,请使用 -gpu=tripcount:warn
,或使用 -gpu=tripcount:nowarn
来禁用这些警告。
要在运行时检查主机和设备行程计数值是否相同,请使用 -gpu=tripcount:check
。设置环境变量 NVCOMPILER_ACC_CHECK_TRIPCOUNT
以启用报告发现的任何差异。要禁用这些检查,请使用 -gpu=tripcount:nocheck
。
6.7.1. 何时使用 -gpu=tripcount:device
或 -gpu=tripcount:host
考虑以下示例代码片段
real :: array(1000, 10)
integer :: i, j, n, m
!$acc data create(n, m) copy(array)
!$acc kernels
n = 1000
m = 10
!$acc end kernels
!$acc parallel loop defualt(none) collapse(2)
do j=1,m
do i=1,n
array(i, j) = i+j
end do
end do
行程计数变量 n
和 m
在设备上创建,然后它们的值在设备上的 acc kernels
结构中设置。它们的值未在主机上设置。因此,当并行循环在设备上运行时,如果使用主机上 n
和 m
的值,则循环将不会运行正确的迭代次数。在这种和类似情况下,为了确保程序的正确性,应使用 -gpu=tripcount:device
。
在 n
和 m
的值在主机上设置的情况下,依赖默认行为或指定 -gpu=tripcount:host
就足够了。有两种方法可以验证程序的正确性是否可能受到使用 -gpu=tripcount:device
与 -gpu=tripcount:host
的影响。-gpu=tripcount:check
选项可用于检测运行时主机和设备行程计数值之间的差异,-gpu=tripcount:warn
选项可用于发出编译时警告,指示可能会使用主机的行程计数值。
注意:对于 CUDA Graphs,-gpu=tripcount:device
允许行程计数在设备上捕获的图的运行之间变化,只要行程计数在设备上更新即可。此行为可能会影响 CUDA Graphs 的正确性,并且某些应用程序可能需要此选项才能正确使用 CUDA Graphs。
6.8. 环境变量
本节总结了 NVIDIA OpenACC 支持的环境变量。这些环境变量是用户可设置的环境变量,用于控制启用加速器的程序在执行时的行为。这些环境变量必须符合以下规则
环境变量的名称必须为大写。
环境变量的值不区分大小写,并且可能包含前导和尾随空格。
如果在程序启动后环境变量的值发生更改,即使程序本身修改了这些值,其行为也是实现定义的。
下表包含当前支持的环境变量,并提供了每个变量的简要说明。
使用此环境变量… |
要做到这一点… |
---|---|
NVCOMPILER_ACC_CHECK_TRIPCOUNT |
当使用 |
NVCOMPILER_ACC_CUDA_PROFSTOP |
设置为 1(或任何正值)以告知运行时环境在退出时插入 'atexit(cuProfilerStop)' 调用。如果分析不完整或发出消息以调用 cudaProfilerStop(),则可能需要此行为。 |
NVCOMPILER_ACC_DEVICE_NUM |
设置要使用的默认设备编号。NVCOMPILER_ACC_DEVICE_NUM。指定执行加速器区域时要使用的默认设备编号。此环境变量的值必须是零到连接到主机的设备数量之间的非负整数。 |
ACC_DEVICE_NUM |
旧版名称。已被 NVCOMPILER_ACC_DEVICE_NUM 取代。 |
NVCOMPILER_ACC_DEVICE_TYPE |
设置用于 OpenACC 区域的默认设备类型。NVCOMPILER_ACC_DEVICE_TYPE。指定当程序已编译为使用多种不同类型的设备时,执行加速器区域时要使用的加速器设备。此环境变量的值是实现定义的,在 NVIDIA OpenACC 实现中可能是字符串 NVIDIA、MULTICORE 或 HOST |
ACC_DEVICE_TYPE |
旧版名称。已被 NVCOMPILER_ACC_DEVICE_TYPE 取代。 |
NVCOMPILER_ACC_GANGLIMIT |
对于 NVIDIA CUDA 设备,这定义了内核将启动的最大 gang(CUDA 线程块)数量。 |
NVCOMPILER_ACC_NOTIFY |
在没有参数的情况下,将为每个内核启动和/或数据传输向 stderr 写入调试消息。当设置为整数值时,该值用作位掩码,以打印有关以下内容的信息:1:内核启动 2:数据传输 4:区域进入/退出 8:等待操作或与设备的同步 16:设备内存分配和释放 |
NVCOMPILER_ACC_PROFLIB |
使用新的分析器动态库接口启用第三方工具接口。 |
NVCOMPILER_ACC_SYNCHRONOUS |
禁用异步启动和数据移动。 |
NVCOMPILER_ACC_TIME |
启用轻量级分析器以测量数据移动和加速器内核执行时间,并在程序执行结束时打印摘要。 |
6.9. 分析加速器内核
对分析器/跟踪工具接口的支持
NVIDIA HPC 编译器支持 OpenACC 分析器/跟踪工具接口。这是 NVIDIA 分析器用于收集 OpenACC 程序性能测量的接口。
使用 NVCOMPILER_ACC_TIME
将环境变量 NVCOMPILER_ACC_TIME 设置为非零值会启用收集和打印有关加速器区域和生成的内核的简单计时信息。
注意
启用 NVCOMPILER_ACC_TIME 时,请关闭所有 CUDA 分析器(NVIDIA 的 Visual Profiler、NVPROF、CUDA_PROFILE 等),它们使用相同的库来收集性能数据,并且不能同时使用。
加速器内核计时数据
bb04.f90
s1
15: region entered 1 times
time(us): total=1490738
init=1489138 region=1600
kernels=155 data=1445
w/o init: total=1600 max=1600
min=1600 avg=1600
18: kernel launched 1 times
time(us): total=155 max=155 min=155 avg=155
在此示例中,发生了很多事情
对于每个加速器区域,都会打印文件名 bb04.f90 和子例程或函数名称 s1,以及加速器区域的行号,在本例中为 15。
库会计算区域被进入的次数(示例中为 1 次)以及在该区域花费的微秒数(在本例中为 1490738),这分为初始化时间(在本例中为 1489138)和执行时间(在本例中为 1600)。
然后,执行时间将分为内核执行时间和主机与 GPU 之间的数据传输时间。
对于每个内核,都会给出其行号(示例中为 18),以及内核启动的计数,以及在内核中花费的总时间、最大时间、最小时间和平均时间,在本例中均为 155。
6.10. OpenACC 运行时库
本节概述了程序员可调用函数和库例程,程序员可以使用它们来查询加速器功能并在运行时控制启用加速器的程序的行为。
注意
在 Fortran 中,OpenACC 运行时库例程都不能从 PURE 或 ELEMENTAL 过程调用。
6.10.1. 运行时库定义
Fortran 以及 C++ 和 C 都有单独的运行时库文件。
C++ 和 C 运行时库文件
在 C++ 和 C 中,运行时库例程的原型在名为 accel.h
的头文件中提供。所有库例程都是具有 'C' 链接的 extern
函数。此文件定义了
本节中所有例程的原型。
这些原型中使用的任何数据类型,包括用于描述加速器类型的枚举类型。
Fortran 运行时库文件
在 Fortran 中,接口声明在名为 accel_lib.h
的 Fortran 包含文件和名为 accel_lib
的 Fortran 模块中提供。这些文件定义了
本节中所有例程的接口。
用于定义这些例程参数的整数种类的整数参数。
用于描述加速器类型的整数参数。
6.10.2. 运行时库例程
表 17 列出了 NVIDIA HPC 编译器除了标准 OpenACC 运行时 API 例程之外还支持的运行时库例程,并对其进行了简要描述。
此运行时库例程… |
执行此操作… |
---|---|
acc_allocs |
返回在数据或计算区域中分配的数组数量。 |
acc_bytesalloc |
返回数据或计算区域分配的总字节数。 |
acc_bytesin |
返回通过数据或计算区域复制到加速器的总字节数。 |
acc_bytesout |
返回通过数据或计算区域从加速器复制出的总字节数。 |
acc_copyins |
返回通过数据或计算区域复制到加速器的数组数量。 |
acc_copyouts |
返回通过数据或计算区域从加速器复制出的数组数量。 |
acc_disable_time |
告诉运行时停止分析加速器区域和内核。 |
acc_enable_time |
告诉运行时开始分析加速器区域和内核(如果尚未这样做)。 |
acc_exec_time |
返回加速器上执行内核所花费的微秒数。 |
acc_frees |
返回在数据或计算区域中释放或取消分配的数组数量。 |
acc_get_device |
返回用于运行下一个加速器区域的加速器设备类型(如果已选择)。 |
acc_get_device_num |
返回用于执行加速器区域的设备编号。 |
acc_get_free_memory |
返回连接的加速器设备上的可用空闲内存总量。 |
acc_get_memory |
返回连接的加速器设备上的总内存。 |
acc_get_num_devices |
返回连接到主机的给定类型的加速器设备数量。 |
acc_kernels |
返回自程序启动以来启动的加速器内核数量。 |
acc_present_dump |
总结当前设备上存在的所有数据。 |
acc_present_dump_all |
总结所有设备上存在的所有数据。 |
acc_regions |
返回自程序启动以来进入的加速器区域数量。 |
acc_total_time |
返回在加速器计算区域和移动加速器数据区域的数据时花费的微秒数。 |
6.11. 支持的内联函数
内联函数是给定语言中可用的函数,其实现由编译器专门处理。通常,内联函数会将一系列自动生成的指令替换为原始函数调用。由于编译器对内联函数有深入的了解,因此它可以更好地集成它并针对具体情况进行优化。
内联函数使处理器特定的增强功能更容易使用,因为它们为汇编指令提供了语言接口。通过这样做,编译器管理用户通常需要关注的事项,例如寄存器名称、寄存器分配和数据的内存位置。
本节概述了加速器支持的 Fortran 和 C 内联函数。
6.11.1. 支持的 Fortran 内联函数摘要表
表 18 是加速器支持的 Fortran 内联函数的按字母顺序排列的摘要。除非另有说明,否则这些函数特定于 Fortran 90/95。
在大多数情况下,为内联函数有效的所有数据类型提供支持。当仅对某些数据类型提供支持时,表中的中间列会使用以下代码指定哪些数据类型
I 表示整数 |
S 表示单精度实数 D 表示双精度实数 |
C 表示单精度复数 Z 表示双精度复数 |
此内联函数 |
返回值 |
|
---|---|---|
ABS |
I,S,D |
参数的绝对值。 |
ACOS |
指定参数的反余弦。 |
|
AINT |
将参数截断为整数。 |
|
ANINT |
实数参数的最接近整数。 |
|
ASIN |
参数的反正弦。 |
|
ATAN |
参数的反正切。 |
|
ATAN2 |
复数值 first-argument + i*second-argument 的弧度角。 |
|
COS |
S,D,C,Z |
参数的余弦。 |
COSH |
参数的双曲余弦。 |
|
DBLE |
S,D |
将参数转换为双精度实数。 |
DPROD |
两个单精度参数的双精度乘积。 |
|
EXP |
S,D,C,Z |
参数的自然指数值。 |
IAND |
两个整数参数的逻辑与的结果。 |
|
IEOR |
两个整数参数的布尔异或的结果。 |
|
INT |
I,S,D |
将参数转换为整数类型。 |
IOR |
两个整数参数的布尔包含或的结果。 |
|
LOG |
S,D,C,Z |
参数的以 e 为底的对数(自然对数)。 |
LOG10 |
参数的以 10 为底的对数。 |
|
MAX |
参数的最大值。 |
|
MIN |
参数的最小值。 |
|
MOD |
I |
第一个参数除以第二个参数的余数。 |
NINT |
实数参数的最接近整数。 |
|
NOT |
整数参数的逻辑补码。 |
|
REAL |
I,S,D |
将参数转换为实数。 |
SIGN |
第一个参数的绝对值乘以第二个参数的符号。 |
|
SIN |
S,D,C,Z |
参数的正弦。 |
SINH |
参数的双曲正弦。 |
|
SQRT |
S,D,C,Z |
参数的平方根。 |
TAN |
参数的正切。 |
|
TANH |
参数的双曲正切。 |
6.11.2. 支持的 C 内联函数摘要表
本节包含两个按字母顺序排列的摘要 – 一个用于 double 函数,第二个用于 float 函数。这些列表仅包含加速器支持的那些 C 内联函数。
此内联函数 |
返回值 |
---|---|
acos |
参数的反余弦。 |
asin |
参数的反正弦。 |
atan |
参数的反正切。 |
atan2 |
y/x 的反正切,其中 y 是第一个参数,x 是第二个参数。 |
cos |
参数的余弦。 |
cosh |
参数的双曲余弦。 |
exp |
参数的指数值。 |
fabs |
参数的绝对值。 |
fmax |
两个参数的最大值 |
fmin |
两个参数的最小值 |
log |
参数的自然对数。 |
log10 |
参数的以 10 为底的对数。 |
pow |
第一个参数的第二个参数次幂的值。 |
sin |
参数的正弦值。 |
sinh |
参数的双曲正弦。 |
sqrt |
参数的平方根。 |
tan |
参数的正切。 |
tanh |
参数的双曲正切。 |
此内联函数 |
返回值 |
---|---|
acosf |
参数的反余弦。 |
asinf |
参数的反正弦。 |
atanf |
参数的反正切。 |
atan2f |
y/x 的反正切,其中 y 是第一个参数,x 是第二个参数。 |
cosf |
参数的余弦。 |
coshf |
参数的双曲余弦。 |
expf |
参数的指数值。 |
fabsf |
参数的绝对值。 |
logf |
参数的自然对数。 |
log10f |
参数的以 10 为底的对数。 |
powf |
第一个参数的第二个参数次幂的值。 |
sinf |
参数的正弦值。 |
sinhf |
参数的双曲正弦。 |
sqrtf |
参数的平方根。 |
tanf |
参数的正切。 |
tanhf |
参数的双曲正切。 |
7. 使用 OpenMP
OpenMP 是一组编译器指令、应用程序编程接口 (API) 和一组环境变量的规范,可用于指定 Fortran、C++ 和 C 程序中的并行执行。有关使用 OpenMP 的一般信息以及获取 OpenMP 规范副本,请参阅 OpenMP 组织网站。
NVFORTRAN、NVC++ 和 NVC 编译器支持用于 CPU 和 GPU 的 OpenMP 应用程序编程接口的子集。在定义此子集时,我们专注于 OpenMP 5.0 功能,这些功能将为 OpenMP 应用程序实现 CPU 和 GPU 定向,目标是鼓励可移植和可扩展的编程实践。对于要避免的功能,在可能的情况下,与这些功能相关的指令和 API 调用将被解析和忽略,以最大限度地提高可移植性。如果忽略此类功能是不可能的,或者可能导致歧义或不正确的执行,则编译器会在编译时或运行时发出相应的错误消息。
为 GPU 正确构建的 OpenMP 应用程序(意味着它们暴露了大规模并行性,并且在 GPU 端代码段中几乎没有或没有同步)应该能够编译和执行,其性能与等效的 OpenACC 相当或接近。对于 GPU 结构不良的代码,性能可能较差,但应能正确执行。
使用 -mp
编译器开关来启用对 OpenMP 指令和编译指示的处理。-mp
最重要的子选项如下
gpu
:OpenMP 指令被编译为 GPU 执行以及多核 CPU 后备;此功能在 NVIDIA V100 或更高版本的 GPU 上受支持。multicore
:OpenMP 指令仅编译为多核 CPU 执行;此子选项是默认选项。
Usage
将隐式添加与编译后的卸载目标对应的以下宏
当 OpenMP 目标指令编译为 GPU 时,使用
__NVCOMPILER_OPENMP_GPU
。当 OpenMP 目标指令编译为多核 CPU 时,使用
__NVCOMPILER_OPENMP_MULTICORE
。
7.1. 环境变量
OpenMP 规范包含许多与程序执行相关的环境变量。
线程亲和性
一个重要的环境变量是 OMP_PROC_BIND
。它控制 OpenMP CPU 线程亲和性策略。当线程亲和性被禁用时,操作系统可以自由地在可用的 CPU 核心之间移动线程。当线程亲和性被启用时,每个线程都绑定到可用 CPU 核心的子集。环境变量 OMP_PLACES
可用于指定如何为每个线程确定可用 CPU 核心的子集。当设置为有效值时,此环境变量将启用线程亲和性并覆盖默认线程亲和性策略。
将线程绑定到某些 CPU 核心通常有利于应用程序性能,因为这可以提高 CPU 缓存命中率并限制不同 NUMA 节点之间的内存事务。因此,考虑为您的应用程序启用线程亲和性非常重要。
OMP_PROC_BIND
的默认值为 false
。因此,默认情况下禁用线程亲和性。这是一个保守的设置,允许某些类别的应用程序(例如 OpenMP + MPI)创建多个进程,而无需特别注意线程亲和性策略,以避免将不同进程中的线程绑定到相同的 CPU 核心。
下表解释了 OMP_PROC_BIND
最简单的可能值。有关 OMP_PROC_BIND
和 OMP_PLACES
的全面解释,请参阅 OpenMP 规范。
值 |
行为 |
---|---|
|
除非 |
|
线程亲和性已启用。除非设置了 |
设备卸载
另一个需要了解的重要环境变量是 OMP_TARGET_OFFLOAD
。使用此环境变量来影响主机和设备上的执行行为,包括主机后备。下表解释了您可以将此环境变量设置为的每个值所确定的行为。
值 |
行为 |
---|---|
|
尝试在 GPU 上执行;如果不支持的 GPU 不可用,则回退到主机 |
|
即使 GPU 可用,也不要在 GPU 上执行;在主机上执行 |
|
在 GPU 上执行或终止程序 |
设备上的团队数量
当应用程序将 omp target teams
结构卸载到 GPU 时,团队数量会自动计算,除非该结构具有 num_teams
子句。团队数量的自动设置可以限制为 OMP_NUM_TEAMS
环境变量提供的最大值。应用程序也可以在运行时使用函数 omp_set_num_teams
设置相同的最大值。
值 |
行为 |
---|---|
|
设备上的最大团队数量 |
有关 OMP_NUM_TEAMS
的全面解释,请参阅 OpenMP 规范。
团队中的线程数
卸载到 GPU 的 omp target teams
结构会创建一个团队联盟,每个团队联盟由一定数量的线程组成。团队联盟中所有团队的线程数都相同,并且会自动计算,除非该结构具有 thread_limit
子句。
环境变量 OMP_TEAMS_THREAD_LIMIT
可用于限制团队中的最大线程数。应用程序可以使用运行时函数 omp_set_teams_thread_limit
设置相同的最大值。
对于 NVIDIA GPU,我们建议使用 32 的倍数的值(这是 GPU 线程 warp 的大小)。这同样适用于 OMP_TEAMS_THREAD_LIMIT
环境变量、omp_set_teams_thread_limit
函数和 thread_limit
子句。对于任何其他值,每个团队的线程数上限可能会向下舍入到最接近的 32 的倍数。相同的指导也适用于 num_threads
子句。
值 |
行为 |
---|---|
|
团队中的最大线程数 |
有关 OMP_TEAMS_THREAD_LIMIT
的全面解释,请参阅 OpenMP 规范。
强制设备团队和线程的数量
在某些情况下,例如为了调试或性能调整,可能需要指定 GPU 上团队和线程的确切数量。虽然 OpenMP 提供了许多方便的方法来控制这一点,例如 num_teams
和 thread_limit
子句,以及上面描述的环境变量,但它们不能保证确切的团队和线程配置。
NVIDIA HPC OpenMP 运行时支持 NVCOMPILER_OMP_CUDA_GRID
环境变量。设置后,它会请求运行时在 GPU 上运行 OpenMP 计算结构时使用确切的团队数和每个团队的线程数。本质上,它的作用是为任何内核使用特定的 CUDA 网格配置,绕过运行时和编译器指导。
值 |
行为 |
---|---|
|
|
但是,即使指定了确切的 CUDA 网格,如果内核成功启动需要更正后的配置,运行时仍然可以使用更正后的配置。
有关 CUDA 内核执行配置如何工作的详细说明,请参阅 CUDA C++ 编程指南。
7.2. 后备模式
当没有 GPU 存在或 OMP_TARGET_OFFLOAD
设置为 DISABLED
时,HPC 编译器支持 OpenMP target
区域的主机后备。执行应始终正确,但目标区域在主机上运行时,性能可能并不总是最佳的。为在 GPU 上进行最佳执行而预先构建的 OpenMP 目标区域在 CPU 的不同架构上运行时可能表现不佳。为了在主机和设备之间提供性能可移植性,我们建议使用 loop
结构。
主机执行不支持带有 nowait 的 firstprivates
当前,对于计划在主机上执行的目标区域(-mp 或 -mp=gpu 与 OMP_TARGET_OFFLOAD=DISABLED
),在 nowait
子句的使用上存在限制。如果目标区域引用了具有 firstprivate
数据共享属性的变量,则不能保证它们的并发更新是安全的。为了解决此限制,在主机上运行时,我们建议避免在此类目标区域上使用 nowait
子句,或者等效地在区域之后立即使用 taskwait
结构。
7.3. Loop
HPC 编译器支持 loop
结构,并扩展了 OpenMP 指定的默认绑定线程集机制,以便编译器可以自由地分析循环和依赖关系,从而为 CPU 和 GPU 目标生成高度并行的代码。换句话说,除非用户明确指定,否则编译器会将 loop
映射到团队或线程,由编译器选择。所选映射特定于每个目标架构,即使在同一可执行文件中(即,GPU 卸载和主机后备),从而有助于性能可移植性。
NVIDIA GPU 提供的并行形状(由线程块和其中的三个线程维度组成)不同于现代 CPU 的多线程向量并行性。下表总结了 OpenMP 到 NVIDIA GPU 和多核 CPU 的映射
构造 |
CPU |
GPU |
---|---|---|
|
启动卸载 |
|
|
单个团队 |
网格中的 CUDA 线程块 |
|
CPU 线程 |
线程块内的 CUDA 线程 |
|
向量指令提示 |
simdlen(1) |
高性能计算 (HPC) 程序需要利用所有可用的并行性来提高性能。程序员可以尝试成为每个目标架构复杂性的专家,并利用这些知识来相应地构建程序。这种指令式模型可以成功,但往往会增加源代码的复杂性,并且通常需要为每个新的目标架构进行重构。这是一个示例,程序员显式地请求编译器应采取哪些步骤将并行性映射到两个目标
#ifdef TARGET_GPU
#pragma omp target teams distribute reduction(max:error)
#else
#pragma omp parallel for reduction(max:error)
#endif
for( int j = 1; j < n-1; j++) {
#ifdef TARGET_GPU
#pragma omp parallel for reduction(max:error)
#endif
for( int i = 1; i < m-1; i++ ) {
Anew[j][i] = 0.25f * ( A[j][i+1] + A[j][i-1]
+ A[j-1][i] + A[j+1][i]);
error = fmaxf( error, fabsf(Anew[j][i]-A[j][i]));
}
}
另一种方法是程序员专注于在程序中暴露并行性,并允许编译器将其映射到目标架构。HPC 编译器实现的 loop
支持这种描述式模型。在此示例中,程序员指定要由编译器并行化的循环区域,而编译器跨团队和线程并行化 loop
#pragma omp target teams loop reduction(max:error)
for( int j = 1; j < n-1; j++) {
#pragma omp loop reduction(max:error)
for( int i = 1; i < m-1; i++ ) {
Anew[j][i] = 0.25f * ( A[j][i+1] + A[j][i-1]
+ A[j-1][i] + A[j+1][i]);
error = fmaxf( error, fabsf(Anew[j][i]-A[j][i]));
}
}
程序员使用 loop
进行调优的工具是 bind
子句。下表扩展了之前的映射示例
构造 |
CPU |
GPU |
---|---|---|
|
线程 |
CUDA 线程块和线程 |
|
线程 |
CUDA 线程 |
|
单线程(对于向量指令很有用) |
单线程 |
支持单个文件内的孤立 loop
结构;必须通过 bind
子句为这些循环指定 parallel
或 thread
的绑定区域。编译器支持包含过程调用的 loop
区域,只要被调用者不包含 OpenMP 指令。
以下是一些使用 loop
的其他示例。我们还展示了使用 -Minfo
编译器选项时编译器将提供的信息类型的示例。
在 Fortran 中使用 loop
!$omp target teams loop
do n1loc_blk = 1, n1loc_blksize
do igp = 1, ngpown
do ig_blk = 1, ig_blksize
do ig = ig_blk, ncouls, ig_blksize
do n1_loc = n1loc_blk, ntband_dist, n1loc_blksize
!expensive computation codes
enddo
enddo
enddo
enddo
enddo
$ nvfortran test.f90 -mp=gpu -Minfo=mp
42, !$omp target teams loop
42, Generating "nvkernel_MAIN__F1L42_1" GPU kernel
Generating Tesla code
43, Loop parallelized across teams ! blockidx%x
44, Loop run sequentially
45, Loop run sequentially
46, Loop run sequentially
47, Loop parallelized across threads(128) ! threadidx%x
42, Generating Multicore code
43, Loop parallelized across threads
使用 loop
、collapse
和 bind
!$omp target teams loop collapse(3)
do n1loc_blk = 1, n1loc_blksize
do igp = 1, ngpown
do ig_blk = 1, ig_blksize
!$omp loop bind(parallel) collapse(2)
do ig = ig_blk, ncouls, ig_blksize
do n1_loc = n1loc_blk, ntband_dist, n1loc_blksize
!expensive computation codes
enddo
enddo
enddo
enddo
enddo
$ nvfortran test.f90 -mp=gpu -Minfo=mp
42, !$omp target teams loop
42, Generating "nvkernel_MAIN__F1L42_1" GPU kernel
Generating Tesla code
43, Loop parallelized across teams collapse(3) ! blockidx%x
44, ! blockidx%x collapsed
45, ! blockidx%x collapsed
47, Loop parallelized across threads(128) collapse(2) ! threadidx%x
48, ! threadidx%x collapsed
42, Generating Multicore code
43, Loop parallelized across threads
使用 loop
、collapse
和 bind(thread)
!$omp target teams loop collapse(3)
do n1loc_blk = 1, n1loc_blksize
do igp = 1, ngpown
do ig_blk = 1, ig_blksize
!$omp loop bind(thread) collapse(2)
do ig = ig_blk, ncouls, ig_blksize
do n1_loc = n1loc_blk, ntband_dist, n1loc_blksize
! expensive computation codes
enddo
enddo
enddo
enddo
enddo
$ nvfortran test.f90 -mp=gpu -Minfo=mp
42, !$omp target teams loop
42, Generating "nvkernel_MAIN__F1L42_1" GPU kernel
Generating Tesla code
43, Loop parallelized across teams, threads(128) collapse(3) ! blockidx%x threadidx%x
44, ! blockidx%x threadidx%x collapsed
45, ! blockidx%x threadidx%x collapsed
47, Loop run sequentially
48, collapsed
42, Generating Multicore code
43, Loop parallelized across threads
7.4. OpenMP 子集
本节包含 HPC 编译器支持的 OpenMP 5.0 功能子集。我们尝试将此功能子集定义为那些尽可能实现 OpenMP-for-GPU 应用程序性能的功能,使其能够紧密地反映 NVIDIA 在 OpenACC 中取得的成功。NVIDIA GPU 上支持的几乎每个功能也在多核 CPU 上得到支持,尽管反之则不然。OpenMP 3.1 和 OpenMP 4.5 中适用于多核 CPU 的大多数结构都支持 CPU 目标,并且还支持 OpenMP 5.0 中的某些功能。
在 NVIDIA V100 或更高版本的 GPU 上支持 OpenMP 目标卸载到 NVIDIA GPU。
以下章节编号对应于 2018 年 11 月 OpenMP 应用程序编程接口版本 5.0 文档中的章节编号。
2. 指令
2.3 变体指令
2.3.4 元指令
target_device
/device
上下文选择器支持 kind
(host
|nohost
|cpu
|gpu
) 和 arch
(nvtpx
|nvptx64
) 特征选择器。arch
特征属性 nvptx
是 nvptx64
的别名;任何其他 arch
特征属性都被视为不匹配或被忽略。isa
选择器也被视为不匹配或被忽略;不提供基于 NVIDIA GPU 计算能力选择上下文的支持。
implementation
上下文选择器支持 vendor(nvidia)
特征选择器。
user
上下文选择器支持 condition(expression)
特征选择器,包括动态 user
特征。
不支持 begin
/end metadirective
语法。
2.3.5 Declare Variant 指令
device
上下文选择器支持 kind
(host
|nohost
|cpu
|gpu
) 和 arch
(nvtpx
|nvptx64
) 特征选择器。arch
特征属性 nvptx
是 nvptx64
的别名;任何其他 arch
特征属性都被视为不匹配或被忽略。isa
选择器也被视为不匹配或被忽略;不提供基于 NVIDIA GPU 计算能力选择上下文的支持。
implementation
上下文选择器支持 vendor(nvidia)
特征选择器;所有其他 implementation 特征选择器都被视为不匹配。
C/C++ 支持 begin
/end declare variant
语法。
2.4 Requires 指令
requires
指令的支持有限。接受 requirement 子句 unified_address
和 unified_shared_memory
,但不起作用。要激活 OpenMP 统一共享内存编程,需要传入命令行选项(有关更多详细信息,请参阅 使用 CUDA 统一内存的 OpenMP)。
2.5 内部控制变量
ICV 支持如下。
支持
dyn-var
、nthread-var
、thread-limit-var
、max-active-levels-var
、active-levels-var
、levels-var
、run-sched-var
、dyn-sched-var
和stacksize-var
仅在 CPU 上支持
place-partition-var
、bind-var
、wait-policy-var
、display-affinity-var
、default-device-var
和target-offload-var
仅在 CPU 上支持
affinity-format-var
;其值是不可变的不支持
max-task-priority-var
、def-allocator-var
不支持
cancel-var
;它始终返回 false
2.6 Parallel 结构
对 parallel
结构子句的支持如下。
支持
num_threads
、default
、private
、firstprivate
和shared
子句支持 2.19.5 中描述的
reduction
子句仅针对 CPU 目标支持
if
和copyin
子句;编译器为 GPU 目标发出错误仅针对 CPU 目标支持
proc_bind
子句;对于 GPU 目标,它将被忽略allocate
子句被忽略
2.7 Teams 结构
仅当 teams
结构嵌套在 target
结构中(该结构不包含 teams
结构之外的语句、声明或指令)时,或者作为组合的 target
teams
结构时,才支持 teams
结构。GPU 目标支持 teams
结构。如果 target
结构回退到 CPU 模式,则团队数量为一个。对 teams
结构子句的支持如下。
支持
num_teams
、thread_limit
、default
、private
和firstprivate
子句支持 2.19.5 中描述的
reduction
子句CPU 目标支持
shared
子句,并且在统一内存模式下 GPU 目标也支持allocate
子句被忽略
2.8 工作共享结构
2.8.1 Sections 结构
仅针对 CPU 目标支持 sections
结构;编译器为 GPU 目标发出错误。对 sections
结构子句的支持如下。
支持
private
和firstprivate
子句支持 2.19.5 中描述的
reduction
子句支持
lastprivate
子句;不支持可选的lastprivate
修饰符allocate
子句被忽略
2.8.2 Single 结构
对 single
结构子句的支持如下。
支持
private
、firstprivate
和nowait
子句仅针对 CPU 目标支持
copyprivate
子句;编译器为 GPU 目标发出错误allocate
子句被忽略
2.8.3 Workshare 结构
仅在 Fortran 中针对 CPU 目标支持 workshare
结构;编译器为 GPU 目标发出错误。
2.9 循环相关结构
2.9.2 工作共享循环结构 (for/do)
对工作共享 for
和 do
结构子句的支持如下。
支持
private
、firstprivate
和collapse
子句支持 2.19.5 中描述的
reduction
子句支持
schedule
子句;不支持可选的修饰符支持
lastprivate
子句;不支持可选的lastprivate
修饰符仅针对 CPU 目标支持
ordered
子句;不支持ordered(n)
子句不支持
linear
子句order(concurrent)
子句被忽略allocate
子句被忽略
2.9.3 SIMD 指令
simd
结构可用于为 CPU 目标提供调优提示;对于 GPU 目标,simd
结构将被忽略。对 simd
结构子句的支持如下。
支持 2.19.5 中描述的
reduction
子句支持
lastprivate
子句;不支持可选的lastprivate
修饰符不支持
if
、simdlen
和linear
子句safelen
、aligned
、nontemporal
和order(concurrent)
子句被忽略
复合 for
simd
和 do
simd
结构受 CPU 目标支持;对于 GPU 目标,它们被视为 for
和 do
指令。复合结构上支持受支持的 simd
子句(针对 CPU)。对于 GPU 目标,任何 simd
子句都将被忽略。
declare
simd
指令被忽略。
2.9.4 Distribute 指令
在 teams
结构中支持 distribute
结构。对 distribute
结构子句的支持如下
支持
private
、firstprivate
、collapse
和dist_schedule(static [ ,chunksize])
子句不支持
lastprivate
子句allocate
子句被忽略
distribute simd
结构被视为 distribute
结构,并且受 GPU 目标支持;接受有效的受支持 distribute
子句;simd
子句被忽略。CPU 目标不支持 distribute simd
结构。
GPU 目标支持 distribute
parallel
for
或 distribute
parallel
do
结构。接受有效的受支持 distribute
、parallel
以及 for
或 do
子句。CPU 目标不支持 distribute
parallel
for
或 distribute
parallel
do
结构。
distribute parallel `for simd
或 distribute parallel do simd
结构被视为 distribute parallel for
或 distribute parallel do
结构,并且受 GPU 目标支持。CPU 目标不支持这些结构。
2.9.5 Loop 结构
对 loop
结构子句的支持如下。
支持
private
、bind
和collapse
子句支持 2.19.5 中描述的
reduction
子句假定
order(concurrent)
子句不支持
lastprivate
子句
2.10 任务构造
2.10.1 Task 结构
CPU 目标支持 task
结构。当编译器在 target
结构中遇到 task
时,会发出错误。对 task
结构子句的支持如下
支持
if
、final
、default
、private
、firstprivate
和shared
子句支持 2.17.11 中描述的
depend([dependmodifier,] dependtype : list)
子句
2.10.4 Taskyield 结构
CPU 目标支持 taskyield
结构;对于 GPU 目标,它将被忽略。
2.11 内存管理指令
不支持内存管理分配器、内存管理 API 例程和内存管理指令
2.12 设备指令
2.12.1 设备初始化
根据程序的编译和链接方式,设备初始化可能发生在第一个 target
结构或 API 例程调用时,或者可能在程序启动时隐式发生。
2.12.2 Target Data 结构
GPU 目标支持 target data
结构。对 target data
结构子句的支持如下。
支持
if
、device
、use_device_ptr
和use_device_addr
子句支持 2.19.7 中描述的
map
子句
2.12.3 Target Enter Data 结构
GPU 目标支持 target enter data
结构。对 enter data
结构子句的支持如下。
支持
if
、device
和nowait
子句支持 2.19.7 中描述的
map
子句。支持 2.17.11 中描述的
depend([dependmodifier,] dependtype : list)
子句
2.12.4 Target Exit Data 结构
GPU 目标支持 target exit data
结构。对 exit data
结构子句的支持如下。
支持
if
、device
和nowait
子句支持 2.19.7 中描述的
map
子句。支持 2.17.11 中描述的
depend([dependmodifier,] dependtype : list)
子句
2.12.5 Target 结构
GPU 目标支持 target
结构。如果没有 GPU 或 GPU 卸载被禁用,则执行回退到 CPU 模式。对 target
结构子句的支持如下
支持
if
、private
、firstprivate
、is_device_ptr
和nowait
子句支持不带 device-modifier
ancestor
关键字的device
子句支持 2.19.7 中描述的
map
子句支持使用 OpenMP 5.0 语义的
defaultmap
子句支持 2.17.11 中描述的
depend([dependmodifier,] dependtype : list)
子句allocate
和uses_allocate
子句被忽略
2.12.6 Target Update 结构
GPU 目标支持 target update
结构。对 target update
结构子句的支持如下。
支持
if
、device
和nowait
子句。支持不带
mapper
或mapid
的to
和from
子句支持 2.17.11 中描述的
depend([dependmodifier,] dependtype : list)
子句
在 to
和 from
子句中支持数组切片,包括非连续数组切片。不支持数组切片步长。如果数组切片是非连续的,则 OpenMP 运行时可能必须使用多个主机到设备或设备到主机的数据传输操作,这会增加开销。如果主机数据位于主机固定内存中,则带有 nowait
子句的 update
数据传输是异步的。这意味着 target update to nowait
的数据传输可能不会立即或与程序线程同步发生,并且对数据的任何更改都可能会影响传输,直到到达同步操作。类似地,target update from nowait
可能不会立即或与程序线程同步发生,并且下载的数据可能在到达同步操作之前不可用。如果主机数据不在主机固定内存中,则带有 nowait
子句的 update
数据传输可能需要数据传输操作使用由 OpenMP 运行时库管理的中间固定缓冲区,并且在开始或完成传输操作之前可能需要在主机上在程序内存和固定缓冲区之间进行内存复制操作,这会影响开销和性能。要了解有关固定缓冲区的更多信息,请参阅 暂存内存缓冲区 <acc-mem-pinned-buffer>。
2.12.7 Declare Target 结构
GPU 目标支持 declare target
结构。
支持
declare target ... end declare target
支持
declare target(list)
支持
to(list)
子句C/C++ 支持
device_type
子句
在 declare target to
子句(显式或隐式)中出现的函数或过程引用的函数或过程,将被视为其名称已隐式出现在 declare target to
子句中。
2.13 组合结构
在组件结构本身受支持的范围内,支持组合结构。
2.14 组合和复合结构的子句
在组件结构上支持子句的范围内,支持组合结构上的子句。
2.16 Master 结构
CPU 和 GPU 目标都支持 master
结构。
2.17 同步结构和子句
2.17.1 Critical 结构
仅针对 CPU 目标支持 critical
结构;编译器为 GPU 目标发出错误。
2.17.2 Barrier 结构
支持 barrier
结构。
2.17.3 隐式 Barrier
已实现隐式 barrier。
2.17.4 实现相关的 Barrier
可能存在实现相关的 barrier,并且对于 CPU 目标和 GPU 目标,它们可能不同。
2.17.5 Taskwait 结构
仅针对 CPU 目标支持 taskwait
结构;对于 GPU 目标,它将被忽略。
支持 2.17.11 中描述的
depend([dependmodifier,] dependtype : list)
子句
2.17.6 Taskgroup 结构
仅针对 CPU 目标支持 taskgroup
结构。对于 GPU 目标,它将被忽略。
2.17.7 Atomic 结构
对 atomic
结构子句的支持如下。
支持
read
、write
、update
和capture
子句。不支持内存顺序子句
seq_cst
、acq_rel
、release
、acquire
、relaxed
hint
子句被忽略
2.17.8 Flush 结构
仅针对 CPU 目标支持 flush
结构。
2.17.9 Ordered 结构和 Ordered 指令
仅针对 CPU 目标支持 ordered
块结构。
2.17.11 Depend 子句
CPU 目标支持 depend
子句。GPU 目标不支持。支持依赖类型 in
、out
和 inout
。不支持依赖类型 mutexinoutset
和 depobj
、依赖修饰符 iterator(iters)
、depend(source)
和 depend(sink:vector)
。
2.19 数据环境
2.19.2 Threadprivate 指令
仅针对 CPU 目标支持 threadprivate
指令。GPU 目标不支持;不支持在设备代码中引用 threadprivate
变量。
2.19.5 Reduction 子句和指令
支持 reduction
子句。不支持可选的修饰符。
2.19.6 数据复制子句
数据复制 copyin
和 copyprivate
子句仅支持 CPU 目标;编译器会为 GPU 目标发出编译时错误。
2.19.7 数据映射属性规则、子句和指令
支持
map([[mapmod[,]...] maptype:] datalist)
子句。在 map-type-modifiers 中,支持always
,忽略close
,不支持mapper(mapid)
。支持使用 OpenMP 5.0 语义的
defaultmap
子句。
2.20 区域嵌套
对于此子集中支持的构造,区域嵌套受到限制。此外,不支持 CPU 上的嵌套并行区域,也不支持目标区域中的嵌套 teams 或并行区域。
运行时库例程
3.2 执行环境例程
支持以下执行环境运行时 API 例程。
omp_set_num_threads
、omp_get_num_threads
、omp_get_max_threads
、omp_get_thread_num
、omp_get_thread_limit
、omp_get_supported_active_levels
、omp_set_max_active_levels
、omp_get_max_active_levels
、omp_get_level
、omp_get_ancestor_thread_num
、omp_get_team_size
、omp_get_num_teams
、omp_get_team_num
、omp_is_initial_device
以下执行环境运行时 API 例程仅在 CPU 上受支持。
omp_get_num_procs
、omp_set_dynamic
、omp_get_dynamic
、omp_set_schedule
、omp_get_schedule
、omp_in_final
、omp_get_proc_bind
、omp_get_num_places
、omp_get_affinity_format
、omp_set_default_device
、omp_get_default_device
、omp_get_num_devices
、omp_get_device_num
、omp_get_initial_device
以下执行环境运行时 API 例程具有有限的支持。
omp_get_cancellation
、omp_get_nested
;仅在 CPU 上受支持;返回的值始终为 falseomp_display_affinity
、omp_capture_affinity
;仅在 CPU 上受支持;格式说明符被忽略omp_set_nested
;仅在 CPU 上受支持,该值被忽略
以下执行环境运行时 API 例程不受支持。
omp_get_place_num_procs
、omp_get_place_proc_ids
、omp_get_place_num
、omp_get_partition_num_places
、omp_get_partition_place_nums
、omp_set_affinity_format
、omp_get_max_task_priority
、omp_pause_resource
、omp_pause_resource_all
3.3 锁例程
GPU 上不支持锁运行时 API 例程。CPU 上支持以下锁运行时 API 例程。
omp_init_lock
、omp_init_nest_lock
、omp_destroy_lock
、omp_destroy_nest_lock
、omp_set_lock
、omp_set_nest_lock
、omp_unset_lock
、omp_unset_nest_lock
、omp_test_lock
、omp_test_nest_lock
以下锁运行时 API 例程不受支持。
omp_init_lock_with_hint
、omp_init_nest_lock_with_hint
3.4 计时例程
支持以下计时运行时 API 例程。
omp_get_wtime
、omp_get_wtick
3.6 设备内存例程
以下设备内存例程仅在 CPU 上受支持。
omp_target_is_present
、omp_target_associate_ptr
、omp_target_disassociate_ptr
omp_target_memcpy
和omp_target_memcpy_rect
仅在复制到和复制自同一设备时受支持。
以下设备内存例程在 CPU 上受支持;我们扩展了 OpenMP 以在 GPU 上的目标区域中支持这些例程,但仅支持在同一设备上的分配和释放。
omp_target_alloc
、omp_target_free
3.7 内存管理例程
支持以下内存管理例程。
omp_alloc
、omp_free
以下内存管理例程不受支持。
omp_init_allocator
、omp_destroy_allocator
、omp_set_default_allocator
、omp_get_default_allocator
6 环境变量
以下环境变量具有有限的支持。
OMP_SCHEDULE
、OMP_NUM_THREADS
、OMP_NUM_TEAMS
、OMP_DYNAMIC
、OMP_PROC_BIND
、OMP_PLACES
、OMP_STACKSIZE
、OMP_WAIT_POLICY
、OMP_MAX_ACTIVE_LEVELS
、OMP_NESTED
、OMP_THREAD_LIMIT
、OMP_TEAMS_THREAD_LIMIT
、OMP_DISPLAY_ENV
、OMP_DISPLAY_AFFINITY
、OMP_DEFAULT_DEVICE
和OMP_TARGET_OFFLOAD
在 CPU 上受支持。OMP_CANCELLATION
和OMP_MAX_TASK_PRIORITY
被忽略。OMP_AFFINITY_FORMAT
、OMP_TOOL
、OMP_TOOL_LIBRARIES
、OMP_DEBUG
和OMP_ALLOCATOR
不受支持
7.5. 使用 metadirective
本节包含影响 metadirective
的限制以及一些使用指南。
Fortran 编译器不支持导致需要相应 end
指令的 OpenMP 指令的变体。
嵌套 user
条件虽然合法,但可能会创建 HPC 编译器无法优雅处理的情况。为避免潜在问题,请在 user
条件内使用 device
特征。以下示例最好地说明了这种最佳实践。
避免像这样嵌套动态 user
条件
#pragma omp metadirective \
when( user={condition(use_offload)} : target teams distribute) \
default( parallel for schedule(static) )
for (i = 0; i < N; i++) {
...
#pragma omp metadirective \
when( user={condition(use_offload)} : parallel for)
for (j = 0; j < N; j++) {
...
}
...
}
相反,像这样在动态 user
条件内使用 target_device
和 device
特征
#pragma omp metadirective \
when( target_device={kind(gpu)}, user={condition(use_offload)} : target teams distribute) \
default( parallel for schedule(static) )
for (i = 0; i < N; i++) {
...
#pragma omp metadirective \
when( device={kind(gpu)} : parallel for)
for (j = 0; j < N; j++) {
...
}
...
}
HPC 编译器不支持在应用于语法块的 target
构造内嵌套 metadirective
,从而导致 teams
变体。一些例子
编译器将为以下代码发出错误
#pragma omp target map(to:v1,v2) map(from:v3)
{
#pragma omp metadirective \
when( device={arch("nvptx")} : teams distribute parallel for) \
default( parallel for)
for (int i = 0; i < N; i++) {
v3[i] = v1[i] * v2[i];
}
}
给定以下代码,编译器将始终匹配 device={arch("nvptx")}
#pragma omp target map(to:v1,v2) map(from:v3)
#pragma omp metadirective \
when( device={arch("nvptx")} : teams distribute parallel for) \
default( parallel for)
for (int i = 0; i < N; i++) {
v3[i] = v1[i] * v2[i];
}
给定以下代码,编译器将为 GPU 代码匹配 device={"arch")
,为主机回退匹配 default
#pragma omp target teams distribute map(to:v1,v2) map(from:v3)
for (...)
{
#pragma omp metadirective \
when( device={arch("nvptx")} : parallel for) \
default( simd )
for (int i = 0; i < N; i++) {
v3[i] = v1[i] * v2[i];
}
}
7.6. 将 target 构造映射到 CUDA 流
OpenMP 目标任务生成构造在 CUDA 流中的 GPU 上执行。以下是目标任务生成构造
target enter data
target exit data
target update
target
本节解释了这些目标构造如何映射到 CUDA 流。下面还解释了与 OpenACC 队列的关系。
请记住,target data
构造不生成任务,也不一定在 CUDA 流中执行。它也不能有 depend
和 nowait
子句,因此其行为不能由用户应用程序直接控制。本节的其余部分不涵盖 target data
构造的行为。
任何生成任务的目标构造都可以具有 depend
和 nowait
子句。NVIDIA OpenMP 运行时将这些子句作为如何将构造映射到特定 CUDA 流的指导。以下是这些子句如何影响映射决策的细分。
没有 ‘depend’,没有 ‘nowait’ 的 ‘target’
对于这些构造,通常使用每个线程的默认 CUDA 流。该流对于每个主机线程都是唯一的,因此由不同主机线程创建的目标区域将根据 CUDA 运行时 API 中描述的 CUDA 规则在不同的流中独立执行;请参阅“每个线程的默认流”部分中的规则。
OpenACC 队列 acc_async_sync
最初与同一个每个线程的默认 CUDA 流相关联。允许用户通过调用 acc_set_cuda_stream(acc_async_sync, stream)
来更改关联。这将相应地更改用于没有 nowait
的 target
的流。
CUDA 流句柄可以直接通过 ompx_get_cuda_stream(int device, int nowait)
函数获得,其中 nowait
参数设置为 0。每个线程的默认流可以使用 CUDA 句柄 CU_STREAM_PER_THREAD
或 cudaStreamPerThread
获得。
以下是如何使用自定义 CUDA 流来替换默认流的示例
extern __global__ void kernel(int *data);
CUstream stream;
cuStreamCreate(&stream, CU_STREAM_DEFAULT);
acc_set_cuda_stream(acc_async_sync, stream);
#pragma omp target enter data map(to:data[:N])
#pragma omp target data use_device_ptr(data)
kernel<<<N/32, 32, 0, stream>>>(data);
#pragma omp target teams distribute parallel for
for (int i = 0; i < N; i++) {
data[i]++;
}
#pragma omp target exit data map(from:data[:N])
请注意,在 CUDA kernel
启动后没有显式的流同步。流在随后的 target
构造中自动同步。
带有 ‘depend’,没有 ‘nowait’ 的 ‘target’
对于此构造,运行时将阻塞当前线程,直到 depend
子句中列出的所有依赖项都已解决。然后,target
构造将在默认的每个线程 CUDA 流中执行,如上一节所述(即,就像没有 depend
子句一样)。
带有 ‘nowait’,没有 ‘depend’ 的 ‘target’
默认情况下,运行时将为每个新的 target nowait
构造选择一个 CUDA 流。所选流可能与先前 target nowait
构造使用的流相同。也就是说,不保证所选流的唯一性。
这与 OpenACC 模型不同,OpenACC 模型对任何带有不带参数的 async
子句的异步构造使用与 acc_async_noval
队列关联的同一 CUDA 流。要更改此行为,用户可以调用 ompx_set_cuda_stream_auto(int enable)
函数,并将 enable
参数设置为 0。在这种情况下,与 acc_async_noval
OpenACC 队列关联的 CUDA 流将用于所有 OpenMP target nowait
构造。启用此行为的另一种方法是将环境变量 NVCOMPILER_OMP_AUTO_STREAMS
设置为 FALSE
。
要访问用于下一个 target nowait
构造的流,用户可以调用 ompx_get_cuda_stream(int device, int nowait)
函数,并将 nowait
参数设置为 1。
同时带有 ‘depend’ 和 ‘nowait’ 的 ‘target’
在这种情况下,决定使用哪个 CUDA 流取决于先前计划的目标任务和主机任务,这些任务共享 depend
子句中列出的依赖项的子集
如果目标构造只有一个依赖项,其类型为
inout
或out
,并且该依赖项映射到先前计划的target depend(...) nowait
构造,并且两个目标构造都使用相同的设备,则将使用先前目标任务计划到的 CUDA 流。否则,将根据流选择策略为此目标构造选择一个 CUDA 流。
请注意,具有单个 in
依赖项的目标构造可以计划在新选择的 CUDA 流上。这是为了允许并行执行多个 target nowait
构造,这些构造依赖于由另一个先前计划的 target nowait
构造生成的数据。
这是一个简化的示例,说明如何在 GPU 上在同一流中异步于主机线程执行 target
构造、CUDA 库函数和 CUDA 内核
extern __global__ void kernel(int *data);
cudaStream_t stream = (cudaStream_t)ompx_get_cuda_stream(omp_get_default_device(), 1);
cufftSetStream(cufft_plan, stream);
#pragma omp target enter data map(to:data[:N]) depend(inout:stream) nowait
#pragma omp target data use_device_ptr(data)
{
kernel<<<N/32, 32, 0, stream>>>(data);
cufftExecC2C(cufft_plan, data, data, CUFFT_FORWARD);
}
#pragma omp target teams distribute parallel for depend(inout:stream) nowait
for (int i = 0; i < N; i++) {
data[i]++;
}
#pragma omp target exit data map(from:data[:N]) depend(inout:stream) nowait
请注意,stream
变量保存 CUDA 流句柄,并且还充当 target
构造的依赖项。此依赖项强制执行执行顺序,并保证目标构造与从 ompx_get_cuda_stream
函数调用返回的流在同一流上。
NVIDIA OpenMP API 访问和控制 CUDA 流
NVIDIA OpenMP 运行时提供了以下 API 来访问 CUDA 流并控制其使用。
void *ompx_get_cuda_stream(int device, int nowait);
此函数返回将用于下一个 target
构造的 CUDA 流的句柄
如果
nowait
参数设置为 0,则返回与 OpenACC 队列acc_async_sync
关联的 CUDA 流的句柄,该队列最初映射到默认的每个线程 CUDA 流;否则,它返回一个 CUDA 流,该流将用于无法根据
depend
子句的规则映射到现有流的下一个target nowait
构造。
void ompx_set_cuda_stream_auto(int enable);
此函数设置 CUDA 流如何为 target nowait
构造选择的策略
如果
enable
参数设置为非零值,则内部选择的 CUDA 流将用于随后的每个target nowait
构造。这是默认行为;否则,与 OpenACC 队列
acc_async_noval
关联的 CUDA 流将用于随后的所有target nowait
构造。如果环境变量NVCOMPILER_OMP_AUTO_STREAMS
设置为FALSE
,则这将成为默认行为。
该设置仅针对调用此函数的主机线程完成。
7.7. 非连续数组切片
数组切片可以用于 to
和 from
子句,包括非连续数组切片。非连续数组切片必须在单个 map
子句中指定;它不能在多个指令之间拆分。尽管此功能可能会成为未来 OpenMP 规范的一部分,但目前它是 NVIDIA HPC 编译器扩展。
7.8. OpenMP 与 CUDA 统一内存
本节将重点介绍 OpenMP 统一共享内存编程,并假设用户熟悉 内存模型 和 托管和统一内存模式 部分中解释的“分离”、“托管”和“统一内存模式”。OpenMP 统一共享内存对应于 NVHPC 编译器中的“统一内存模式”,可以使用 -gpu=mem:unified
标志启用。“requires unified_shared_memory
”指令被接受,但需要使用 -gpu=mem:unified
标志来激活“统一内存模式”。
在“统一内存模式”下,target
构造上的 map
子句是可选的。此外,对于在应用了此类指令的函数内部访问的具有静态存储持续时间的变量,declare target
指令是可选的。OpenMP 统一共享内存简化了 GPU 上的加速器编程,无需进行数据管理,而只需表达计算区域中的并行性。
在“统一内存模式”下,所有数据都由 CUDA 运行时管理。显式数据 map
子句(用于管理主机和设备之间的数据移动)变为可选。所有变量都可以从 GPU 上执行的 OpenMP 卸载计算区域访问。map
子句(类型为 alloc
、to
、from
和 tofrom
)不会导致任何设备分配或数据传输。但是,OpenMP 运行时可能会利用此类子句通过内存提示 API 将首选数据放置位置传达给 CUDA 运行时,如 NVIDIA 网站上的以下博客文章中所述:通过异构内存管理简化 GPU 应用程序开发。设备内存可以在“统一内存模式”下的 OpenMP 程序中使用 omp_target_alloc
和 omp_target_free
API 调用进行分配或释放。请注意,主机无法访问通过 omp_target_alloc
分配的内存。
理解数据移动
当编译器遇到没有可见 target data
指令或 map
子句的计算构造时,它会尝试确定 GPU 上区域正确执行所需的数据。当编译器无法确定需要在设备上访问的数据的大小和形状时,其行为如下
在“分离内存模式”下,编译器可能无法警告您需要显式数据子句来指定复制到/从 GPU 的数据的大小和/或形状。在这种情况下,可以使用默认长度 1。这可能会导致 GPU 设备上运行时出现非法内存访问错误。
在“托管内存模式”(
-gpu=mem:managed
) 下,编译器假定数据分配在托管内存中,因此可以从设备访问;如果此假设是错误的,例如,如果数据是全局定义的或位于 CPU 堆栈上,则程序可能会在运行时失败。在 Unified Memory 模式 (
-gpu=mem:unified
) 下,所有数据都可以从设备访问,从而使有关大小和形状的信息变得不必要。
以以下 C 语言示例为例
#pragma omp declare target
void set(int* ptr, int i, int j, int dim){
int idx = i * dim + j;
return ptr[idx] = someval(i, j);
}
#pragma omp end declare target
void fill2d(int* ptr, int dim){
#pragma omp target teams distribute parallel for
for (int i = 0; i < dim; i++)
for (int j = 0; j < dim; j++)
set(ptr, i, j, dim);
}
在“分离内存模式”下,保证此示例正确性的唯一方法是在 target
构造中指定数组切片,如下所示
#pragma omp target teams distribute parallel for map(from: ptr[0:dim*dim])
此更改显式指示 OpenMP 实现有关目标 for 循环中使用的精确数据段的信息。
在“统一内存模式”下,不需要 map
子句。
以下 Fortran 示例说明了如何在 OpenMP 例程中访问全局变量,而无需任何显式注释。
module m
integer :: globmin = 1234
contains
subroutine findmin(a)
!$omp declare target
integer, intent(in) :: a(:)
integer :: i
do i = 1, size(a)
if (a(i) .lt. globmin) then
globmin = a(i)
endif
end do
end subroutine
end module m
为 Unified Memory 模式编译上面的示例
nvfortran -mp=gpu -gpu=mem:unified example.f90
源代码不需要任何 OpenMP 指令即可访问模块变量 globmin
,以便读取或更新其值,无论是在从 CPU 和 GPU 调用的例程中。此外,对 globmin
的任何访问都将访问来自 CPU 和 GPU 的变量的完全相同的实例;其值会自动同步。在“分离”或“托管内存模式”下,只有结合使用源代码中的 OpenMP declare target
和 target update
指令才能实现这种行为。
在大多数情况下,迁移为“分离内存模式”编写的现有 OpenMP 应用程序应该是一个无缝的过程,无需更改源代码。但是,某些数据访问模式可能会导致在“统一内存模式”下应用程序执行期间产生不同的结果。依赖于在 GPU 内存中拥有单独的数据副本以在 GPU 上进行临时计算(而无需维护与 CPU 的数据同步)的应用程序对迁移到统一内存提出了挑战。对于以下 Fortran 示例,最后一个循环后变量 c
的值将因示例是否使用 -gpu=mem:unified
编译而异。
b(:) = ...
c = 0
!$omp target data map(to: b) map(from: a)
!$omp target distribute teams parallel for
do i = 1, N
b(i) = b(i) * i
end do
!$omp target distribute teams parallel for
do i = 1, N
a(i) = b(i) + i
end do
!$omp end target data
do i = 1, N
c = c + a(i) + b(i)
end do
不使用统一内存时,数组 b
在 OpenMP target data
区域的开始处被复制到 GPU 内存中。然后在 GPU 内存中更新该数组,并用于计算数组 a
的元素。根据数据子句 map(to:b)
的指示,b
在 target data
区域结束时不会被复制回 CPU 内存,因此其初始值用于 c
的计算。使用 -mp=gpu -gpu=mem:unified
,第一个循环中 b
的更新值在最后一个循环中自动可见,从而导致 c
在结束时具有不同的值。
异步执行可能会带来额外的复杂性,因为使用统一共享内存可能需要额外的同步来避免数据竞争。
7.9. 多设备支持
一个程序可以在单个节点上使用多个设备。
此功能通过 omp_set_default_device
API 调用和 target
构造上的 device()
子句来支持。我们的经验是,大多数程序使用 MPI 并行,每个 MPI 进程选择一个 GPU 进行卸载。有些程序为每个 GPU 分配多个 MPI 进程,以便保持 GPU 完全占用,尽管 GPU 的固定内存大小限制了这种策略的有效性。同样,其他程序在 CPU 上使用 OpenMP 线程并行,每个线程选择一个 GPU 进行卸载。
7.10. 与 CUDA 的互操作性
HPC 编译器对 OpenMP 和 CUDA 互操作性的支持程度与它们对 CUDA 与 OpenACC 互操作性的支持程度相同。
如果 OpenMP 和 CUDA 代码在同一程序中共存,则 OpenMP 运行时和 CUDA 运行时在每个 GPU 上使用相同的 CUDA 上下文。要启用这种共存,请使用编译和链接选项 -cuda
。CUDA 分配的数据可用于 OpenMP target 区域内,使用 OpenMP 模拟 is_device_ptr
来代替 OpenACC 的 deviceptr()
子句。
如果数据是通过 omp_target_alloc()
API 调用分配的,则 OpenMP 分配的数据可以直接在 CUDA 内核中使用;如果 OpenMP 数据是通过 target data map
子句创建的,则可以使用 target data use_device_addr()
子句使其在 CUDA 内核中可用。支持在 OpenMP target 区域内调用 CUDA 设备函数,只要 CUDA 函数是标量函数,即不使用 CUDA 共享内存或任何线程间同步。支持在 CUDA 内核中调用 OpenMP declare target
函数,只要 declare target
函数没有 OpenMP 构造或 API 调用。
7.11. 与其他 OpenMP 编译器的互操作性
使用 NVIDIA HPC 编译器编译的 OpenMP CPU 并行目标文件可以与使用 KMPC OpenMP 运行时接口的其他编译器编译的 OpenMP CPU 并行目标文件互操作。支持 KMPC OpenMP 的编译器包括 Intel 和 CLANG。HPC 编译器也支持 GNU OpenMP 接口层,该接口层提供与 GNU 编译器的 OpenMP CPU 并行互操作性。
对于 OpenMP GPU 计算,没有类似的正式或非正式标准库接口用于启动 GPU 计算构造或管理 GPU 内存。也没有标准方法来管理设备上下文,以便在多个卸载库之间进行互操作。因此,HPC 编译器不支持设备计算卸载操作与使用其他编译器生成的类似操作之间的互操作性。
7.12. GNU STL
在 Linux 上使用 nvc++ 时,GNU STL 的线程安全性达到 GNU 文档中 C++11 标准要求的程度。如果怀疑存在 STL 线程安全问题,可以使用 #pragma omp critical
节段在 OpenMP 区域内顺序运行可疑代码。
8. 使用 Stdpar
本章介绍 NVIDIA HPC 编译器对标准语言并行性(也称为 Stdpar)的支持
使用
nvc++
的 ISO C++ 标准库并行算法使用
nvfortran
的 ISO Fortrando concurrent
循环构造
使用 -stdpar
编译器选项启用标准并行性的并行执行。-stdpar
的子选项如下
gpu
:为 GPU 上的并行执行编译;此子选项是默认选项。NVIDIA Pascal 架构和更新的架构支持此功能。multicore
:为多核 CPU 执行编译。
默认情况下,NVC++ 会自动检测并为编译器运行时系统上安装的 GPU 类型生成 GPU 代码。要为特定的 GPU 架构生成代码(当应用程序在不同的系统上编译和运行时,这可能是必要的),请添加 -gpu=ccXX
命令行选项。更多详细信息可以在 计算能力 中找到。
Usage
以下与编译的并行执行目标对应的宏被隐式添加
__NVCOMPILER_STDPAR_GPU
用于 GPU 上的并行执行。__NVCOMPILER_STDPAR_MULTICORE
用于多核 CPU 上的并行执行。
8.1. GPU 内存模式
当为 GPU 执行编译时,Stdpar 利用 托管内存和统一内存模式 来管理从 CPU 上运行的顺序代码和 GPU 上的并行代码访问的数据。
编译器检测编译器运行的系统的内存能力,并使用该信息来启用正确的内存模式,如下所示
当在具有完整 CUDA 统一内存能力的平台上编译时,
-stdpar
意味着-gpu=mem:unified
。当仅在具有 CUDA 托管内存能力的平台上编译时,
-stdpar
意味着-gpu=mem:managed
。
要为特定的内存模式编译代码,而不管您正在编译的系统的内存能力如何,请添加所需的 -gpu=mem:unified
或 -gpu=mem:managed
选项。
仅当数据通过其他编程模型(例如 OpenACC)的功能完全管理时,才能支持具有独立内存模式的 Stdpar。
当使用统一内存模式时,已删除对托管内存模式下标准语言并行代码中 GPU 上使用的变量的所有限制。
如果编译器自动利用 CUDA 托管内存,则在运行时隐式启用对释放的拦截。这是为了防止使用不匹配的 API 释放数据,这可能会导致未定义的行为。拦截会产生一些运行时开销,如果应用程序中所有数据的分配和释放都使用匹配的 API 执行,则可能是没有必要的。可以使用 释放拦截 中详细介绍的专用命令行选项禁用拦截。有关 NVIDIA HPC 编译器支持的内存模式和专用命令行选项的更多详细信息,请参见 内存模型。
8.2. Stdpar C++
NVIDIA HPC C++ 编译器 NVC++ 支持 C++ 标准语言并行性 (Stdpar),用于在 NVIDIA GPU 和多核 CPU 上执行。如前所述,使用 NVC++ 命令行选项 -stdpar
启用 GPU 加速的 C++ 并行算法。以下部分将更详细地介绍 NVC++ 对 ISO C++ 标准库并行算法的支持。
8.2.1. Stdpar C++ 简介
C++17 标准引入了更高级别的并行性功能,允许用户请求标准库算法的并行化。
这种更高级别的并行性通过添加执行策略作为支持执行策略的任何算法的第一个参数来表示。大多数现有的标准 C++ 算法都得到了增强,以支持执行策略。C++17 定义了几个新的并行算法,包括有用的 std::reduce 和 std::transform_reduce。
C++17 定义了三个 执行策略
std::execution::seq:
顺序执行。不允许并行性。std::execution::par:
在一个或多个线程上并行执行。std::execution::par_unseq:
在一个或多个线程上并行执行,每个线程可能被向量化。
当您使用 std::execution::seq
以外的执行策略时,您正在向编译器传达两个重要信息
您希望但不要求该算法并行运行。符合 C++17 标准的实现可以忽略此提示并顺序运行该算法,但面向性能的实现会接受此提示并在可能和谨慎的情况下并行执行。
该算法可以安全地并行运行。对于
std::execution::par
和std::execution::par_unseq
策略,任何用户提供的代码(例如,迭代器、lambda 或传递给算法的函数对象)如果并发运行在单独的线程上,则不得引入数据竞争。对于std::execution::par_unseq
策略,如果同一线程上的多个调用交错,则任何用户提供的代码都不得引入数据竞争或死锁,这正是循环向量化时发生的情况。有关潜在死锁的更多信息,请参阅并行策略提供的 前向进度保证 或观看 CppCon 2018: Bryce Adelstein Lelbach “C++ 执行模型”。
C++ 标准赋予编译器很大的自由度来选择是否、何时以及如何并行执行算法,只要满足用户请求的前向进度保证即可。例如,std::execution::par_unseq
可以通过向量化实现,而 std::execution::par
可以通过 CPU 线程池实现。也可以在 GPU 上执行并行算法,这对于具有足够并行性的调用来说是一个不错的选择,可以利用 NVIDIA GPU 处理器的处理能力和内存带宽。
8.2.2. NVC++ 编译器并行算法支持
NVC++ 支持 C++ 标准语言并行性,并行执行策略为 std::execution::par
或 std::execution::par_unseq
,用于在 GPU 或多核 CPU 上执行。
Lambda(包括泛型 lambda)在并行算法调用中得到完全支持。无需语言扩展或非标准库即可启用 GPU 加速。主机内存和 GPU 设备内存之间的所有数据移动都在 托管内存和统一内存模式 的控制下隐式且自动地执行。
使用 NVC++ 自动 GPU 加速 C++ 并行算法非常简单。但是,您需要注意一些限制和局限性,如下所述。
8.2.2.1. 使用 -stdpar 选项启用并行算法
C++ 并行算法的 GPU 加速是通过 NVC++ 的 -stdpar=gpu
命令行选项启用的。如果指定了 -stdpar=gpu
(或没有参数的 -stdpar
),则几乎所有使用并行执行策略的算法都将编译为卸载到 NVIDIA GPU 上并行运行
nvc++ -stdpar=gpu program.cpp -o program
nvc++ -stdpar program.cpp -o program
此外,可以使用 -stdpar=gpu:acc
进一步专门化 GPU 加速子选项。此选项指示编译器使用其 OpenACC 实现来 GPU 加速具有并行执行策略的算法子集
nvc++ -stdpar=gpu:acc program.cpp -o program
有关 Stdpar C++ 的 OpenACC 支持的更多详细信息,请参见 并行算法的 OpenACC 实现。
使用 NVC++ 的 -stdpar=multicore
命令行选项启用多核 CPU 加速 C++ 并行算法。如果指定了 -stdpar=multicore
,则几乎所有使用并行执行策略的算法都将编译为在多核 CPU 上运行
nvc++ -stdpar=multicore program.cpp -o program
当为 NVC++ 指定 -stdpar=gpu,multicore
或 -stdpar=gpu:acc,multicore
命令行选项时,并行算法代码将编译为同时支持 GPU 和多核 CPU。当执行平台有任何 GPU 时,二进制文件将在 GPU 上执行,否则将在多核 CPU 上执行。
nvc++ -stdpar=gpu,multicore program.cpp -o program
nvc++ -stdpar=gpu:acc,multicore program.cpp -o program
8.2.3. Stdpar C++ 简单示例
以下是一些简单的示例,让您了解 C++ 并行算法的工作方式。
从 C++ 的早期开始,使用如下的单个调用对存储在适当容器中的项目进行排序就相对容易了
std::sort(employees.begin(), employees.end(),
CompareByLastName());
假设比较类 CompareByLastName
是线程安全的(对于大多数比较函数来说是这样),使用 C++ 并行算法并行化此排序很简单。包含 <execution>
并向函数调用添加执行策略
std:sort(std::execution::par,
employees.begin(), employees.end(),
CompareByLastName());
使用 std::accumulate
算法计算容器中所有元素的总和也很简单。在 C++17 之前,在求和的同时以某种方式转换数据有点笨拙。例如,要计算员工的平均年龄,您可以编写以下代码
int ave_age =
std::accumulate(employees.begin(), employees.end(), 0,
[](int sum, const Employee& emp){
return sum + emp.age();
})
/ employees.size();
C++17 中引入的 std::transform_reduce
算法使并行化此代码变得简单。它还通过将归约操作(在本例中为 std::plus
)与转换操作(在本例中为 emp.age():
)分离,从而使代码更简洁:
int ave_age =
std::transform_reduce(std::execution::par_unseq,
employees.begin(), employees.end(),
0, std::plus<int>(),
[](const Employee& emp){
return emp.age();
})
/ employees.size();
8.2.4. 并行算法的 OpenACC 实现
NVC++ 对通过 OpenACC 实现加速的具有并行执行策略 std::par
和 std::par_unseq
的算法子集提供实验性 GPU 支持。此功能通过 -stdpar=gpu:acc
选项启用,可以提高 GPU 上的应用程序性能并加快编译速度。
以下算法子集具有 OpenACC 实现支持
std::for_each
std::for_each_n
std::transform
以下算法对于标量数据类型和标准 std::plus
归约操作具有 OpenACC 实现支持
std::reduce
std::transform_reduce
其余的并行算法使用默认 GPU 实现进行并行化,就像指定了 -stdpar=gpu
一样。
当代码使用 OpenACC 加速 __NVCOMPILER_STDPAR_OPENACC_GPU
宏为 GPU 编译时,将隐式定义。
8.2.5. GPU 加速并行算法的编码指南
GPU 不仅仅是具有更多线程的 CPU。为了有效地利用 GPU 上可用的大规模并行性和内存带宽,GPU 编程模型通常会对在 GPU 上执行的代码施加一些限制。NVC++ 的 C++ 并行算法实现在这方面也不例外。以下部分详细介绍了当前版本中应用的限制。
8.2.5.1. 并行算法和设备函数注解
要在并行算法中在 GPU 上执行的函数不需要任何 __device__
注解或其他特殊标记即可编译为 GPU 执行。NVC++ 编译器遍历每个源文件的调用图,并自动推断哪些函数必须编译为 GPU 执行。
但是,这仅在编译器可以在调用函数的同一源文件中看到函数定义时才有效。对于大多数内联函数和模板函数来说是这样,但当函数在不同的源文件中定义或从外部库链接进来时,可能会失败。在制定您期望在 NVIDIA GPU 上卸载和加速的并行算法调用时,您需要注意这一点。
当从并行算法区域内调用外部定义的函数时,此类函数需要来自其他 GPU 编程模型的某种形式的设备注解,例如 OpenACC 例程指令(有关更多信息,请参阅 外部设备函数注解)。
8.2.5.2. 并行算法中的数据管理
将并行算法卸载到 GPU 时,必须考虑如何从并行区域访问数据。某些 GPU 可能无法访问 CPU 地址空间的某些段。针对没有统一共享内存的平台或寻求优化性能的开发人员必须注意这些内存区别,因为它们可能会影响并行算法区域中访问的以下类型的数据
传递到并行算法中 lambda 函数的指针数据。
lambda 函数中按引用捕获的数据或按值捕获的指针数据。
在并行算法内部引用的具有静态存储持续时间的变量。
为避免内存访问冲突,开发人员必须确保在执行并行算法之前,GPU 可以访问上述所有数据。
Stdpar C++ 仅支持 托管内存和统一内存模式,这些模式允许从 CPU 和 GPU 访问数据。通过 CUDA 设备驱动程序和 NVIDIA GPU 硬件中的支持,CUDA 统一内存管理器会根据使用情况自动移动某些类型的数据。
仅当数据通过 OpenACC 数据指令完全管理时,才能支持具有独立内存模式的 Stdpar C++,请参阅 与 OpenACC 的互操作性。
由于面向对象的设计是 C++ 的基础,因此必须特别考虑具有指针或引用成员的复合数据类型。引用或指向的数据可能不会在复合数据类型中连续存储。此外,此类数据甚至可能未分配在与复合类型本身相同的内存段中。因此,当从并行算法访问复合数据类型及其引用或指向的数据时,开发人员必须确保成员数据也可以被 GPU 访问。当在并行算法中使用标准库容器时,也应考虑这些注意事项,因为容器经常包含指向其元素的成员指针。
本节中的讨论假定您熟悉 内存模型 和 托管内存和统一内存模式 中介绍的托管内存和统一内存模式。在并行算法内执行的代码称为加速器子程序。与此相反,在并行算法外部执行的代码称为主机子程序。
托管内存模式
当 Stdpar 代码以托管内存模式(作为默认模式或通过传递 -gpu=mem:managed
)编译时,只有在 CPU 代码堆上动态分配的数据才能自动托管。CPU 和 GPU 自动存储(堆栈内存)和静态存储(全局或静态数据)不能自动托管。同样,即使数据位于 CPU 堆上,在未使用 -stdpar
选项通过 nvc++
编译的程序单元中动态分配的数据也不会由 CUDA 统一内存自动托管。编译器利用 CUDA 托管内存进行动态分配,以使 CPU 和 GPU 可以访问数据。由于托管内存分配调用可能会产生比标准分配器调用更高的运行时开销,因此实现默认情况下使用内存池以提高性能,如 内存池分配器 <gpu-mem-poolallocator> 中详细介绍的那样。
托管内存模式旨在用于仅具有 CUDA 托管内存功能的目标上运行的二进制文件。在并行算法调用中取消引用的任何指针以及引用的任何 C++ 对象都必须引用 CPU 堆上的数据,这些数据是在程序单元中分配的,该程序单元由 nvc++
使用 -stdpar
编译。取消引用指向 CPU 堆栈或全局对象的指针将导致 GPU 代码中的内存违规。
统一内存模式
当统一内存是默认内存模式或通过在命令行中显式传递 -gpu=mem:unified
选择统一内存时,对并行算法中访问的变量没有限制。因此,所有 CPU 数据(无论是驻留在堆栈、堆还是全局中)都可以在并行算法函数中简单地访问。请注意,在 GPU 代码中动态分配的内存仅对 GPU 代码可见,并且永远无法被 CPU 访问,而与 CUDA 统一内存功能无关。
当为具有完整 CUDA 统一内存功能的平台编译二进制文件时,只有使用标准并行算法库功能的源文件必须由 nvc++
使用 -stdpar
选项编译。不需要以这种方式编译动态分配 GPU 上访问的内存的代码。
统一内存模式可以使用 CUDA 托管内存进行动态分配,更多详细信息可以在 托管内存和统一内存模式 中找到。
总结
下表提供了选择内存模式的重要命令行选项以及内存模式对 Stdpar 功能影响的关键总结。
命令行选项 |
并行算法区域外部的动态分配变量 |
并行算法区域外部的自动或静态存储变量 |
动态分配器 |
未传递特定于内存的标志,仅在具有 CUDA 托管内存的目标上编译 |
可以在并行区域代码中访问 |
无法在并行算法代码中访问 |
cudaMallocManaged |
未传递特定于内存的标志,在具有完整 CUDA 统一内存的目标上编译 |
可以在并行区域代码中访问 |
可以在并行算法代码中访问 |
cudaMallocManaged 或系统分配器:new/malloc(编译器选择最合适的分配器) |
|
可以在并行区域代码中访问 |
无法在并行算法代码中访问 |
cudaMallocManaged |
|
可以在并行区域代码中访问 |
可以在并行算法代码中访问 |
cudaMallocManaged 或系统分配器:new/malloc(编译器选择最合适的分配器) |
|
可以在并行区域代码中访问 |
可以在并行算法代码中访问 |
cudaMallocManaged |
|
可以在并行区域代码中访问 |
可以在并行算法代码中访问 |
系统分配器:new/malloc |
示例
例如,std::vector
使用动态分配的内存,当使用 Stdpar 时,GPU 可以访问该内存。当使用 -gpu=mem:managed
或 -gpu=mem:unified
编译时,在并行算法中迭代 std::vector
的内容按预期工作
std::vector<int> v = ...;
std::sort(std::execution::par,
v.begin(), v.end()); // Okay, accesses heap memory.
另一方面,std::array
不执行动态分配。其内容存储在 std::array
对象本身中,该对象通常位于 CPU 堆栈上。除非 std::array
本身分配在堆上并且代码使用 -gpu=mem:managed
编译,否则在仅具有 CUDA 托管内存支持的系统上迭代 std::array
的内容将不起作用
std::array<int, 1024> a = ...;
std::sort(std::execution::par,
a.begin(), a.end()); // Fails on targets with CUDA Managed
// Memory capability only, array is on
// a CPU stack inaccessible from GPU.
// Works correctly on targets whith full
// CUDA Unified Memory support.
当在支持完整 CUDA 统一内存功能的目标上运行时,上述示例按预期工作。
当在仅具有 CUDA 托管内存功能的目标上执行时,请特别注意 lambda 捕获,尤其是按引用捕获数据对象,这可能包含不明显的指针解引用
void saxpy(float* x, float* y, int N, float a) {
std::transform(std::execution::par_unseq, x, x + N, y, y,
[&](float xi, float yi){ return a * xi + yi; });
}
在前面的示例中,包含函数参数 a
是按引用捕获的。lambda 主体内的代码(在 GPU 上运行)尝试访问 a
,它位于 CPU 堆栈内存中。此尝试会导致内存违规和未定义的行为。在这种情况下,可以通过将 lambda 更改为按值捕获来轻松解决问题
void saxpy(float* x, float* y, int N, float a) {
std::transform(std::execution::par_unseq, x, x + N, y, y,
[=](float xi, float yi){ return a * xi + yi; });
}
通过这一处字符更改,lambda 复制了 a
,然后将其复制到 GPU,并且没有尝试从 GPU 代码引用 CPU 堆栈内存。此类代码将在目标上正确运行,而无需在具有完整 CUDA 统一内存功能的目标上进行修改。
如果从设备通过下标运算符访问 std::vector
,则这将要求可以从在 GPU 上执行的并行代码访问此类 vector 对象。这意味着 std::vector
需要动态分配,以便在为仅具有 CUDA 托管内存支持的系统编译时可以从 GPU 访问它。
std::vector<int> v = ...;
std::for_each(std::execution::par,
idx.begin(), idx.end(), [&](auto i)
{v[i] = 1;}); // Fails on targets with CUDA Managed
// Memory capability only, vector object is on
// a CPU stack inaccessible from GPU.
// Works correctly on targets with full
// CUDA Unified Memory support.
在仅具有 CUDA 托管内存支持的系统上管理 std::vector
内容的另一种方法是使用 data()
成员获取指向其元素数据区域的指针。
std::vector<int> v = ...;
int* vdataptr = v.data();
std::for_each(std::execution::par,
idx.begin(), idx.end(), [&](auto i)
{vdataptr[i] = 1;}); // Works, vector elements are in heap
// memory
无论 -gpu=mem:unified
是默认启用还是在命令行上显式传递,并行算法都可以访问全局变量,并且 CPU 和 GPU 对全局变量的访问保持同步。在并行算法中访问全局变量时应格外小心,因为在 GPU 上运行的不同迭代中同时更新可能会导致数据竞争。以下示例说明了在并行算法中安全更新全局变量,因为更新仅发生在一个迭代中。
int globvar = 123;
void foo() {
auto r = std::views::iota(0, N);
std::for_each(std::execution::par_unseq, r.begin(), r.end(),
[](auto i) {
if (i == N - 1)
globvar += 345;
});
// globvar is equal to 468.
}
8.2.5.3. 并行算法和函数指针
编译为在 CPU 或 GPU 上运行的函数必须编译成两个不同的版本,一个版本包含 CPU 机器指令,另一个版本包含 GPU 机器指令。
在当前实现中,函数指针指向函数的 CPU 版本或 GPU 版本。如果您尝试在 CPU 和 GPU 代码之间传递函数指针,这会导致问题。您可能会无意中将指向函数 CPU 版本的指针传递给 GPU 代码。将来,可能会自动且无缝地支持在 CPU 和 GPU 代码边界之间使用函数指针,但当前实现不支持。
函数指针不能传递给要在 GPU 上运行的并行算法,并且不能通过 GPU 代码中的函数指针调用函数。例如,以下代码示例将无法正常工作
void square(int& x) { x = x * x; }
void square_all(std::vector<int>& v) {
std::for_each(std::execution::par_unseq,
v.begin(), v.end(), &square);
}
它将指向函数 square 的 CPU 版本的指针传递给并行 for_each
算法调用。当算法并行化并卸载到 GPU 时,程序无法将函数指针解析为 square
的 GPU 版本。
您通常可以通过使用函数对象来解决此问题,函数对象是具有函数调用运算符的对象。函数对象的调用运算符在编译时被解析为函数的 GPU 版本,而不是像之前的示例中那样在运行时被解析为不正确的 CPU 版本函数。 例如,以下代码示例可以正常工作
struct squared {
void operator()(int& x) const { x = x * x; }
};
void square_all(std::vector<int>& v) {
std::for_each(std::execution::par_unseq,
v.begin(), v.end(), squared{});
}
另一种可能的解决方法是将函数更改为 lambda,因为 lambda 被实现为无名函数对象
void square_all(std::vector<int>& v) {
std::for_each(std::execution::par_unseq, v.begin(), v.end(),
[](int& x) { x = x * x; });
}
如果所讨论的函数太大而无法转换为函数对象或 lambda,则应该可以将对该函数的调用包装在 lambda 中
void compute(int& x) {
// Assume lots and lots of code here.
}
void compute_all(std::vector<int>& v) {
std::for_each(std::execution::par_unseq, v.begin(), v.end(),
[](int& x) { compute(x); });
}
此示例中未使用函数指针。
不幸的是,通过函数指针调用函数的限制意味着目前不支持将多态对象从 CPU 代码传递到 GPU 加速的并行算法,因为虚函数表是使用函数指针实现的。
8.2.5.4. 随机访问迭代器
C++ 标准要求传递给大多数 C++ 并行算法的迭代器是前向迭代器。但是,GPU 上的 C++ 并行算法仅适用于随机访问迭代器。将前向迭代器或双向迭代器传递给 GPU/CPU 加速的并行算法会导致编译错误。将原始指针或标准库随机访问迭代器传递给算法具有最佳性能,但大多数其他随机访问迭代器也能正常工作。
8.2.5.5. 与 C++ 标准库的互操作性
C++ 标准库的大部分可以与 GPU 上的 stdpar 一起使用。
std::atomic<T>
对象在 GPU 代码中工作,前提是T
是四字节或八字节整数类型。对浮点类型进行操作的数学函数(例如
sin
、cos
、log
以及<cmath>
中声明的大多数其他函数)可以在 GPU 代码中使用,并解析为与 CUDA C++ 程序中使用的相同实现。std::complex
、std::tuple
、std::pair
、std::optional
、std::variant
和<type_traits>
受到支持,并在 GPU 代码中按预期工作。
GPU 代码中不支持的 C++ 标准库部分包括 I/O 函数,以及通常任何访问 CPU 操作系统的函数。 作为一种特殊情况,基本的 printf
调用可以在 GPU 代码中使用,并利用与 NVIDIA CUDA C++ 中使用的相同实现。
8.2.5.6. GPU 代码中无异常
与大多数其他 GPU 编程模型一样,在卸载到 GPU 的并行算法调用中,不支持抛出和捕获 C++ 异常。
与某些其他 GPU 编程模型(其中 try/catch 块和 throw 表达式是编译错误)不同,异常代码确实可以编译,但具有非标准行为。 Catch 子句被忽略,如果实际执行,throw 表达式会中止 GPU 内核。 CPU 代码中的异常可以不受限制地工作。
8.2.6. NVC++ 实验性功能
nvc++ 实验性功能通过 –experimental–stdpar 编译器标志启用。 实验性功能头文件通过 <experimental/...>
命名空间公开,并且在较旧的 C++ 版本中提供对这些功能的有限支持。 表 1 列出了所有可用的实验性功能以及使用它们所需的最低语言版本。
功能 |
推荐 |
有限支持 |
标准提案 |
其他说明 |
---|---|---|---|---|
多维跨度 (mdspan) |
C++23 |
C++17 |
||
多维跨度的切片 (submdspan) |
C++23 |
C++17 |
||
多维数组 (mdarray) |
C++23 |
C++17 |
||
发送器和接收器 |
C++23 |
C++20 |
||
线性代数 |
C++23 |
C++17 |
8.2.6.1. 多维跨度
多维跨度 (std::mdspan
) 支持对数据进行可自定义的多维访问。 此功能已添加到 C++23 中(参见 P0009 和后续论文)。 mdspan 简易入门 提供了教程。 参考 mdspan 实现 https://github.com/kokkos/mdspan 也有许多有用的示例。
nvc++ 提供了 <experimental/mdspan>
命名空间中的一个实现,该实现适用于 C++17 或更高版本。 它使不以 C++23 标准版本为目标的应用程序能够使用 mdspan。
nvc++ 还提供了 P0009R17 版本的 submdspan,它仅适用于 C++23 中的 mdspan 布局; 也就是说,它尚未实现 C++26 submdspan (P2630)。
C++23 的 mdspan 使用 operator[]
进行数组访问。 例如,如果 A
是秩为 2 的 mdspan,并且 i
和 j
是整数,则 A[i, j]
访问 A
中第 i
行和第 j
列的元素。 在 C++23 之前,operator[]
仅允许接受一个参数。 C++23 更改了语言,允许任意数量的参数(零个或多个)。 nvc++ 不支持此新的语言功能。 因此,nvc++ 提供的 mdspan 实现允许使用 operator()
作为后备(例如,A(i, j)
而不是 A[i, j]
)。 用户可以通过在包含任何 mdspan 头文件之前将宏 MDSPAN_USE_PAREN_OPERATOR
定义为 1
来手动启用此后备。
以下示例 (godbolt)
#include <experimental/mdspan>
#include <iostream>
namespace stdex = std::experimental;
int main() {
std::array d{
0, 5, 1,
3, 8, 4,
2, 7, 6,
};
stdex::mdspan m{d.data(), stdex::extents{3, 3}};
static_assert(m.rank()==2, "Rank is two");
for (std::size_t i = 0; i < m.extent(0); ++i)
for (std::size_t j = 0; j < m.extent(1); ++j)
std::cout << "m(" << i << ", " << j << ") == " << m(i, j) << "\n";
return 0;
}
编译如下
nvc++ -std=c++17 -o example example.cpp
并输出
m(0, 0) == 0
m(0, 1) == 5
m(0, 2) == 1
m(1, 0) == 3
m(1, 1) == 8
m(1, 2) == 4
m(2, 0) == 2
m(2, 1) == 7
m(2, 2) == 6
8.2.6.2. 发送器和接收器
P2300 - std::execution 提出了一个异步编程模型,以供 C++26 标准采纳。 有关此功能的介绍,请参阅提案的 设计 - 用户侧 部分。 NVIDIA 的发送器和接收器实现是 开源的,其存储库包含许多 有用的示例。 nvc++ 提供了对 NVIDIA 实现的访问,该实现适用于 C++20 或更高版本。 由于该提案仍在不断发展,因此我们的实现尚不稳定。 它本质上是实验性的,并且会进行更改以紧密跟随提案,恕不另行通知。 NVIDIA 实现的结构如下
包含 |
命名空间 |
描述 |
---|---|---|
<stdexec/…> |
::stdexec |
已获准用于 C++ 标准 |
<sexec/…> |
::exec |
通用添加和扩展 |
<nvexec/…> |
::nvexec |
NVIDIA 特定的扩展和自定义 |
以下示例 (godbolt) 构建了一个任务图,其中两个不同的向量 v0 和 v1 分别使用 CPU 线程池和 GPU 流上下文并发批量修改。 然后,此图将执行转移到 CPU 线程池,并在 CPU 上将两个向量添加到 v2 中,返回所有元素的总和
int main()
{
// Declare a pool of 8 worker CPU threads:
exec::static_thread_pool pool(8);
// Declare a GPU stream context:
nvexec::stream_context stream_ctx{};
// Get a handle to the thread pool:
auto cpu_sched = pool.get_scheduler();
auto gpu_sched = stream_ctx.get_scheduler();
// Declare three dynamic array with N elements
std::size_t N = 5;
std::vector<int> v0 {1, 1, 1, 1, 1};
std::vector<int> v1 {2, 2, 2, 2, 2};
std::vector<int> v2 {0, 0, 0, 0, 0};
// Describe some work:
auto work = stdexec::when_all(
// Double v0 on the CPU
stdexec::just()
| exec::on(cpu_sched,
stdexec::bulk(N, [v0 = v0.data()](std::size_t i) {
v0[i] *= 2;
})),
// Triple v1 on the GPU
stdexec::just()
| exec::on(gpu_sched,
stdexec::bulk(N, [v1 = v1.data()](std::size_t i) {
v1[i] *= 3;
}))
)
| stdexec::transfer(cpu_sched)
// Add the two vectors into the output vector v2 = v0 + v1:
| stdexec::bulk(N, [&](std::size_t i) { v2[i] = v0[i] + v1[i]; })
| stdexec::then([&] {
int r = 0;
for (std::size_t i = 0; i < N; ++i) r += v2[i];
return r;
});
auto [sum] = stdexec::sync_wait(work).value();
// Print the results:
std::printf("sum = %d\n", sum);
for (int i = 0; i < N; ++i) {
std::printf("v0[%d] = %d, v1[%d] = %d, v2[%d] = %d\n",
i, v0[i], i, v1[i], i, v2[i]);
}
return 0;
}
编译如下
nvc++ --stdpar=gpu --experimental-stdpar -std=c++20 -o example example.cpp
并输出
sum = 40
v0[0] = 2, v1[0] = 6, v2[0] = 8
v0[1] = 2, v1[1] = 6, v2[1] = 8
v0[2] = 2, v1[2] = 6, v2[2] = 8
v0[3] = 2, v1[3] = 6, v2[3] = 8
v0[4] = 2, v1[4] = 6, v2[4] = 8
8.2.6.3. 线性代数
P1673 - 基于 BLAS 的自由函数线性代数接口 提议基于 std::mdspan 标准化基本线性代数子程序 (BLAS) 标准子集的惯用 C++ 接口。 有关此功能的介绍,请参阅 P1673(C++ 线性代数库)背景和动机。 在 $HPCSDK_HOME/examples/stdpar/stdblas 和 参考实现 的存储库中,有许多有用的示例可用。 详细文档位于 $HPCSDK_HOME/compilers/include/experimental/__p1673_bits/README.md。 nvc++ 提供了对 NVIDIA 实现的访问,该实现适用于 C++17 或更高版本。 由于该提案仍在不断发展,因此我们的实现尚不稳定。 它本质上是实验性的,并且会进行更改以紧密跟随提案,恕不另行通知。 要使用线性代数库工具,必须链接合适的线性代数库:用于 GPU 执行的 cuBLAS(通过 -cudalib=cublas 标志),以及用于 CPU 执行的 CPU BLAS 库。 HPC SDK 捆绑了 OpenBLAS,可以使用 -lblas 链接器标志进行链接。
执行 |
BLAS 库 |
架构 |
编译器标志 |
---|---|---|---|
多核 |
OpenBLAS |
x86_64, aarch64 |
|
GPU |
cuBLAS |
全部 |
|
以下示例 (godbolt)
#include <experimental/mdspan>
#include <experimental/linalg>
#include <vector>
#include <array>
namespace stdex = std::experimental;
int main()
{
constexpr size_t N = 4;
constexpr size_t M = 2;
std::vector<double> A_vec(N*M);
std::vector<double> x_vec(M);
std::array<double, N> y_vec(N);
stdex::mdspan A(A_vec.data(), N, M);
stdex::mdspan x(x_vec.data(), M);
stdex::mdspan y(y_vec.data(), N);
for(int i = 0; i < A.extent(0); ++i)
for(int j = 0; j < A.extent(1); ++j)
A(i,j) = 100.0 * i + j;
for(int j = 0; j < x.extent(0); ++j) x(j) = 1.0 * j;
for(int i = 0; i < y.extent(0); ++i) y(i) = -1.0 * i;
stdex::linalg::matrix_vector_product(A, x, y); // y = A * x
// y = 0.5 * y + 2 * A * x
stdex::linalg::matrix_vector_product(std::execution::par,
stdex::linalg::scaled(2.0, A), x,
stdex::linalg::scaled(0.5, y), y);
// Print the results:
for (int i = 0; i < N; ++i) std::printf("y[%d] = %f\n", i, y(i));
return 0;
}
编译如下以进行 GPU 执行
nvc++ -std=c++17 -stdpar=gpu -cudalib=cublas -o example example.cpp
并编译如下以进行 CPU 执行
nvc++ -std=c++17 -stdpar=multicore -o example example.cpp -lblas
并在两种情况下产生相同的输出
y[0] = 2.500000
y[1] = 252.500000
y[2] = 502.500000
y[3] = 752.500000
8.2.7. Stdpar C++ 更大的示例:LULESH
LULESH 流体动力学迷你应用程序 由劳伦斯利弗莫尔国家实验室开发,旨在压力测试编译器并模拟流体动力学应用程序的性能。 它大约有 9,000 行 C++ 代码,其中 2,800 行是应该并行化的核心计算。
我们将 LULESH 移植到 C++ 并行算法,并在 LULESH 的 GitHub 存储库 上提供了该移植。 要编译它,请安装 NVIDIA HPC SDK,检出 LULESH 存储库的 2.0.2-dev 分支,转到正确的目录,然后 run make
。
git clone --branch 2.0.2-dev https://github.com/LLNL/LULESH.git
cd LULESH/stdpar/build
make run
虽然 LULESH 太大而无法在此处显示整个源代码,但有一些关键代码序列演示了 stdpar 的用法。
LULESH 代码有许多具有大主体且没有循环携带依赖关系的循环,这使得它们成为并行化的良好候选对象。 其中大多数已轻松转换为对 std::for_each_n
的调用,并使用了 std::execution::par
策略,其中传递给 std::for_each_n
的 lambda 的主体与原始循环主体相同。
函数 CalcMonotonicQRegionForElems
是一个示例。 为 OpenMP 编写的循环头如下所示
#pragma omp parallel for firstprivate(qlc_monoq, qqc_monoq, \
monoq_limiter_mult, monoq_max_slope, ptiny)
for ( Index_t i = 0 ; i < domain.regElemSize(r); ++i ) {
C++ 并行算法版本中的此循环头变为 以下内容
std::for_each_n(
std::execution::par, counting_iterator(0), domain.regElemSize(r),
[=, &domain](Index_t i) {
循环体(在本例中几乎有 200 行长)成为 lambda 的主体,但在其他方面与 OpenMP 版本相同。
在许多地方,显式的 for
循环已更改为使用 C++ 并行算法,以更好地表达代码的意图,例如函数 CalcPressureForElems
#pragma omp parallel for firstprivate(length)
for (Index_t i = 0; i < length ; ++i) {
Real_t c1s = Real_t(2.0)/Real_t(3.0) ;
bvc[i] = c1s * (compression[i] + Real_t(1.));
pbvc[i] = c1s;
}
此函数被重写为 如下所示
constexpr Real_t cls = Real_t(2.0) / Real_t(3.0);
std::transform(std::execution::par,
compression, compression + length, bvc,
[=](Real_t compression_i) {
return cls * (compression_i + Real_t(1.0));
});
std::fill(std::execution::par, pbvc, pbvc + length, cls);
8.2.8. 与 OpenACC 的互操作性
在为 GPU 编译 Stdpar 代码时,可以使用 OpenACC 功能的子集。 此子集在此部分中进行了文档记录。 要使用 Stdpar 代码激活 OpenACC 指令识别,请将 -acc 命令行标志添加到 nvc++。
nvc++ -stdpar -acc example.cpp
OpenACC 功能在 OpenACC 规范中详细说明,NVHPC 编译器特定的差异在本指南的 使用 OpenACC 中详细说明。
将 OpenACC 功能与 Stdpar 结合使用,可以更灵活地编写代码。 例如,它允许从并行算法中调用外部函数。 此外,它还为性能调整提供了机会,例如通过显式数据管理。
8.2.8.1. 数据管理指令
当此类算法中访问的数据通过 OpenACC 指令管理时,C++ 并行算法可以卸载到 GPU。 通过 OpenACC 指令完全管理数据后,Stdpar 代码可以使用所有 GPU 内存模式(包括独立内存模式(使用 -gpu=mem:separate
编译))运行。
支持以下数据指令
OpenACC 结构化数据构造指令
OpenACC 非结构化 enter/exit 数据指令
OpenACC host_data 指令
OpenACC update 指令
只有通过引用捕获的数据或通过值捕获的类指针数据以及在并行算法 lambda 中作为参数传递的类指针数据可以通过 OpenACC 进行管理。 在并行算法 lambda 中按值捕获的任何非指针变量或作为 lambda 参数传入的非指针数据都由 C++ 实现管理。 此类数据的副本会自动在可从 GPU 访问的内存中创建。 有关更多详细信息,请参阅 并行算法中的数据管理。
OpenACC 数据管理可以服务于两个主要目的
显式数据管理: 这对于无法隐式管理的数据是必需的,例如在没有完整 CUDA 统一内存支持的平台上以及数据未在 CUDA 托管内存段中分配时。
性能调整: 即使数据位于 GPU 可访问的内存中,也可以通过 OpenACC 功能优化性能。 许多 OpenACC 数据指令和子句为 CUDA 设备驱动程序提供提示,这可以改进隐式数据管理。
数据管理策略可能会因所追求的具体目标而异。 这些差异在适用情况下进行了概述。
通用规则
除了 host_data
之外的所有指令都可用于数据管理任务,例如在 GPU 中分配内存以及在 CPU 和 GPU 之间复制数据。 这些指令可用于确保数据在并行算法执行期间存在于设备上。 另一方面,host_data
构造用于在并行算法中访问数据时在 CPU 和 GPU 地址空间之间进行地址转换。
int n = get_n();
T* in = new T[nelem];
T* out = new T[nelem];
// Data captured by the lambda are managed explicitly with OpenACC
#pragma acc enter data copyin(n, in[0:nelem]) create(out[0:nelem])
#pragma acc host_data use_device(n, in, out)
{
std::for_each(std::execution::par_unseq, r.begin(), r.end(),
[&,in,out](auto i) {
out[i] = in[i] * n;
});
}
#pragma acc exit data copyout(out[0:nelem])
在上面的示例中,通过 lambda 捕获从 std::for_each
访问的所有数据都通过 OpenACC 数据指令显式管理。 由于并行算法内部的数据要么通过引用捕获,要么捕获指针,因此应用程序代码必须确保此类数据可以从 GPU 访问。 为了使非 GPU 驻留数据在并行区域中可访问,必须将此类区域包含在 host_data
构造区域中,并在 use_device
子句中列出所有通过 OpenACC 运行时显式管理的变量。 数据需要在运行时遇到/执行 host_data
指令时存在(复制或创建),并且数据还必须在并行算法执行期间存在。 上述内容的含义是,从主机代码(从 GPU 上执行的并行区域外部)不能额外调用访问 use_device
区域中包含的变量的 lambda,因为通过 host_data
获取的 GPU 中的变量地址可能无法在 CPU 上访问。
注意
如果上面示例中的迭代器是指针类型,则除了 lambda 捕获的数据之外,还需要显式数据管理。
如果以下示例是为独立内存模式 (-gpu=mem:separate) 编译的,则从并行 std::for_each
中调用 fn
可以正常工作,但从任何并行算法函数外部调用则不行,因为需要从 CPU 访问 GPU 上的驻留数据。
int n = get_n();
T* in = new T[nelem];
T* out = new T[nelem];
#pragma acc enter data copyin(n, in[0:nelem]) create(out[0:nelem])
#pragma acc host_data use_device(n, in, out)
{
auto fn = [&,in,out](auto i) { out[i] = in[i] * n;};
std::for_each(std::execution::par_unseq, r.begin(), r.end(), fn);
// The following line would not be legal, fn accesses variables in GPU memory
//std::for_each(r.begin(), r.end(), fn);
}
#pragma acc exit data copyout(out[0:nelem])
注意
将 use_device
与非指针数据类型一起使用的行为是,host_data
区域内的非指针变量的所有出现都转换为在使用 GPU 地址空间中变量的地址之前访问该变量。 这本质上等效于将此类变量 var
的原始出现转换为 dvar = *acc_device(&var)
。
复合数据类型
具有指针成员的复合数据类型也可以显式管理,但需要显式深拷贝才能正确工作,包括指针附加/分离。
struct S {
float *ptr;
}
int idx[N] = {/*...*/};
float arr[N];
S s{arr};
// Deep copying ptr member with OpenACC
#pragma acc enter data copyin(s.ptr[0:N])
#pragma acc enter data copyin(s, idx)
#pragma acc data attach(s.ptr)
#pragma acc host_data use_device(s, idx)
{
std::for_each_n(std::execution::par, idx, N,
[&](int i) { s.ptr[i] += 5.0; });
}
#pragma acc exit data copyout(s.ptr[0:N])
#pragma acc exit data copyout(s)
当上面示例中结构体 S
类型的变量复制到设备时,将执行深拷贝,并单独复制 S.ptr
指向的内容。 指针附加用于确保在从 GPU 访问指针之前,指针的地址更改为设备内存等效地址。 根据副本的顺序,可能不需要指针 attach
子句。
注意
在上面的示例中,类指针迭代器 idx
除了 lambda 捕获的数据之外,还通过 OpenACC 指令进行管理。
标准容器
如果具有非连续存储的标准容器必须在主机代码中与 GPU 内存的显式数据管理一起使用,则唯一可行的选项是直接使用指向数据的原始指针(例如,通过 std::vector
的 data()
成员获得)访问原始数据,除非可以使用数据的迭代器。
std::vector<T> in(nelem);
std::vector<T> out(nelem);
T *inptr=in.data(),*outptr=out.data();
#pragma acc data copyin(inptr[0:nelem]) copyout(outptr[0:nelem])
#pragma acc host_data use_device(inptr,outptr)
{
std::for_each(std::execution::par_unseq, r.begin(), r.end(),
[=](auto i) {
outptr[i] = inptr[i];
});
}
在上面的示例中,向量元素通过指向其元素的原始指针(通过 vector::data()
成员获得)进行访问,它们通过 OpenACC 数据子句进行显式管理。
静态存储数据
全局或静态变量可以使用 OpenACC 数据指令在并行算法中访问,与其他变量类似。
int glob_arr[N] = {/*...*/};
void foo(){
#pragma acc data copy(glob_arr)
#pragma acc host_data use_device(glob_arr)
{
std::for_each_n(std::execution::par, glob_arr, N,
[](int &e) { e += 1; });
}
}
在上面的示例中,全局数组 glob_arr
在 OpenACC 数据指令的帮助下在 GPU 上更新。
成员函数
当数据成员在成员函数内部进行管理时,隐式对象指针 this
需要为了正确性而进行显式管理,因为访问成员始终是通过取消引用对象本身来完成的。
struct S {
float *ptr;
void update_member() {
#pragma acc data copy(ptr[0:N], this)
#pragma acc host_data use_device(ptr, this)
{
std::for_each(std::execution::par, ptr, ptr + N,
[=](float &e) { ptr[&e - ptr] += 5.0; });
}
}
};
GPU 内存模式相关差异
在独立内存模式下,所有数据都必须通过额外的设备分配和主机与设备之间的 memcpy
以及地址转换进行显式管理。 这也适用于托管内存模式下具有自动或静态存储持续时间的变量。
在统一内存模式下,所有数据都由 CUDA 设备驱动程序自动管理。 此外,在托管内存模式下,所有动态分配都由 CUDA 设备驱动程序管理。 数据子句和指令的使用只能将内存使用提示传播到 CUDA 设备驱动程序,这些提示用于提高数据管理性能。 更多详细信息可以在内存模型和 OpenACC 与 CUDA 统一内存 中找到。
由 CUDA 设备驱动程序管理的所有数据都可以从 OpenACC 功能的简化使用中受益,特别是
不需要使用
host_data
指令,因为统一共享内存中数据的主机和设备地址是相同的。不需要使用指针附加或分离,因为统一共享内存中的主机和设备指针是相同的。
以下示例说明了仅使用 OpenACC 数据构造(包含统一内存模式下的 std::for_each
)的简化数据管理。
int n = get_n();
T* in = new T[nelem];
T* out = new T[nelem];
#pragma acc data copyin(in[0:nelem]) copyout(out[0:nelem])
{
std::for_each(std::execution::par_unseq, r.begin(), r.end(),
[&](auto i) {
out[i] = in[i] * n;
});
}
在上面的示例中,我们利用 OpenACC 显式数据管理构造来指示数据如何在 GPU 上用于在 std::for_each
中执行的计算
in
被移动到 GPU 内存中;out
从 GPU 内存中移出。
in
和 out
均通过引用捕获,因此它们的主机地址在 std::for_each
的 lambda 中使用。 标量变量 n
未被管理。 不需要使用 host_data
构造。
当标准容器在数据指令和子句中使用时,也可以管理底层数据集合。 例如,为了指示 std::vector
的元素是从 GPU 访问的,应用程序代码必须首先使用其 data()
成员检索指向数组元素的指针。 然后,此类指针可以在常规数据指令中使用。
std::vector<T> in(nelem);
std::vector<T> out(nelem);
T *inptr=in.data(), *outptr=out.data();
#pragma acc data copyin(inptr[0:nelem]) copyout(outptr[0:nelem])
{
std::for_each(std::execution::par_unseq, r.begin(), r.end(),
[&](auto i) {
out[i] = in[i];
});
}
上面的示例演示了 OpenACC 数据指令与指向 std::vector
元素的原始指针的用法,这可以提高统一内存中数据的内存性能,并且不需要使用 attach/detach 进行向量内容的完整深拷贝。
int n = get_n();
T* in = new T[nelem];
T* out = new T[nelem];
#pragma acc enter data copyin(n)
#pragma acc host_data use_device(n)
{
std::for_each(std::execution::par_unseq, r.begin(), r.end(),
[&, in, out](auto i) {
out[i] = in[i] * n;
});
}
#pragma acc enter data delete(n)
在上面的示例中,in
和 out
是动态分配的,并由 CUDA 设备驱动程序以托管内存模式管理,n
在堆栈上,因此通过 OpenACC 指令显式管理。
8.2.8.2. 外部设备函数注释
使用 OpenACC 例程指令注释允许调用外部设备函数。
// In file1.cpp
extern int foo();
void bar()
{
std::for_each(std::execution::par_unseq, r.begin(), r.end(),
[=](auto i) {
ou[i] = foo();
});
}
// In file2.cpp
#pragma acc routine
int foo(){
return 4;
}
上面的代码可以按如下方式编译/链接
nvc++ -stdpar file1.cpp
nvc++ -acc file2.cpp
nvc++ -stdpar -acc file1.o file2.o
8.2.9. GPU 并行算法入门
要开始使用,请在运行受支持 Linux 版本的基于 x86-64 或 Arm CPU 的系统上下载并安装 NVIDIA HPC SDK。
NVIDIA HPC SDK 可免费下载,并包含所有 NVIDIA 注册开发人员的永久使用许可证,包括访问未来发布的更新。 在您的系统上安装 NVIDIA HPC SDK 后,nvc++ 编译器可在 /opt/nvidia/hpc_sdk
目录结构下使用。
要在 Linux/x86-64 系统上使用包括 nvc++ 在内的编译器,请将目录
/opt/nvidia/hpc_sdk/Linux_x86_64/25.1/compilers/bin
添加到您的路径中。在基于 Arm CPU 的系统上,将
Linux_x86_64
替换为Linux_aarch64
。
8.2.9.1. 支持的 NVIDIA GPU
NVC++ 编译器可以自动将 C++ 并行算法卸载到基于 Volta 架构或更新架构的 NVIDIA GPU。 这些架构包括一些功能(例如独立的线程调度和 CUDA 统一内存的硬件优化),这些功能专门用于支持高性能、通用的并行编程模型(如 C++ 并行算法)。
NVC++ 编译器为 Pascal 架构上的 C++ 并行算法提供有限的支持,Pascal 架构不具备正确支持 std::execution::par
策略所需的独立线程调度。 当为 Pascal 架构 (-gpu=cc60) 编译时,NVC++ 使用 std::execution::par
策略编译算法,以便在 CPU 上进行串行执行。 只有使用 std::execution::par_unseq
策略的算法才会在 Pascal GPU 上调度运行。
8.2.9.2. 支持的 CUDA 版本
NVC++ 编译器构建于 CUDA 库和技术之上,并使用 CUDA 加速 NVIDIA GPU 上的 C++ 并行算法。 要运行 NVC++ 编译的应用程序的 GPU 加速系统必须安装 CUDA 11.2 或更高版本的设备驱动程序。
NVIDIA HPC SDK 编译器附带集成的 CUDA 工具链、头文件和库,以便在编译期间使用,因此无需在系统上安装 CUDA 工具包。
当指定 -stdpar
时,NVC++ 使用与执行编译的系统上安装的 CUDA 驱动程序最匹配的 CUDA 工具链版本进行编译。 要使用不同版本的 CUDA 工具链进行编译,请使用 -gpu=cudaX.Y
选项。 例如,使用 -gpu=cuda11.8
选项指定您的程序应使用 CUDA 11.8 工具链为 CUDA 11.8 系统编译。
8.3. Stdpar Fortran
Fortran 2008 引入了 do concurrent
(DC) 循环构造,表示循环迭代没有相互依赖性。 使用 -stdpar
时,当将 -stdpar
(或 -stdpar=gpu
)传递给 nvfortran
时,此类循环迭代将在 GPU 上并行执行;当将 -stdpar=multicore
传递给 nvfortran
时,将使用 CPU 线程并行执行。 更多详细信息可以在 NVIDIA 网站上的以下博客文章中找到: 使用 GPU 和 NVIDIA HPC SDK 加速 Fortran DO CONCURRENT。
8.3.1. 在 GPU 上的 DO CONCURRENT 中调用例程
当为 GPU 编译时,在 do concurrent
循环体中调用例程可能会受到约束。 PURE 例程通常可以在 do concurrent
循环体内部调用。 编译器检测到此类例程将为 GPU 目标编译。 但是,除非使用 OpenACC 例程指令(请参阅与 OpenACC 的互操作性 <stdpar-fortran-interop-openacc>)或 CUDA 设备属性(请参阅与 CUDA Fortran 的互操作性)显式注释外部例程,否则无法从 DC 循环中调用外部例程。
以下示例将成功编译。
module m
contains
pure subroutine foo()
return
end subroutine
end module m
program dc
use m
implicit none
integer :: i
do concurrent (i=1:10)
call foo()
enddo
end program
但是,除非 foo
是以下之一,否则以下示例不会编译
使用
!$acc routine
注释,或使用
attributes(device)
属性进行属性化,并编译为 Stdpar 和 CUDA Fortran。
program dc
implicit none
interface
pure subroutine foo()
end subroutine foo
end interface
integer :: i
do concurrent (i=1:10)
call foo()
enddo
end program
8.3.2. GPU 数据管理
如果默认启用 -gpu=mem:managed
或在命令行上显式传递,则 do concurrent
循环中的某些数据访问无效。 例如,在从 do concurrent
循环调用的例程中访问全局变量不会在 CPU 代码中执行预期的值更新。
此外,在极少数情况下,编译器无法准确确定 CPU 和 GPU 之间隐式数据移动的变量大小。 正如以下示例所示,a
是一个假定大小的数组,并且由于元素索引位置取自例程外部初始化的另一个数组 b
,因此无法在编译时确定其在 DC 构造内部的访问区域。 此类代码不会按预期更新 a
,并可能导致内存违规和未定义的行为。
subroutine r(a, b)
integer :: a(*)
integer :: b(:)
do concurrent (i = 1 : size(b))
a(b(i)) = i
enddo
end subroutine
当使用 -gpu=mem:unified
编译代码时,无论此选项是默认启用还是通过命令行上的选项显式启用,上面描述的 do concurrent
循环中访问的变量都没有限制。
8.3.3. 与 OpenACC 的互操作性
在为 GPU 编译 Stdpar 代码时,可以使用 OpenACC 功能。 要使用 Stdpar 代码激活 OpenACC 指令识别,请将 -acc
命令行标志添加到 nvfortran
。
nvfortran -stdpar -acc example.f90
OpenACC 功能以及与 DO-CONCURRENT 循环的互操作性在 OpenACC 规范中详细说明,NVIDIA HPC 编译器特定的差异在本指南的 使用 OpenACC 中详细说明。
使用 OpenACC 功能可以增强 DC 循环的功能,例如通过以下方式
显式数据管理,以提高 CPU-GPU 隐式数据移动的性能,甚至在使用
-gpu=mem:separate
编译时,利用 GPU 上的独立内存编译。调整 GPU 上的 DC 循环执行,例如 GPU 内核启动配置。
异步执行 DC 循环。
从 DC 循环中调用外部例程。
DC 循环中的原子操作。
示例
下面提供了一些将 OpenACC 指令与 DC 循环结合使用的示例。
以下示例演示了如何在 OpenACC 数据结构中完全管理 DC 循环内部访问的数据。
!$acc data copyin(b) copyout(a)
do concurrent (j=1:N)
do i=1,K
a(j,i) = b(j,i)
end do
end do
!$acc end data
虽然在上面的示例中,数据结构用于 GPU 数据管理,但通过在包含 DC 循环的计算结构上使用数据子句,可以实现相同的效果。
以下示例展示了如何通过计算结构上的子句控制 GPU 上 DC 循环的调度。
!$acc parallel loop num_gangs(50000) vector_length(32)
do concurrent (i=1:K,j=1:N)
a(j,i) = real(j)
end do
可以在计算结构上使用 OpenACC async 子句,以异步方式执行 DC 循环中的计算。
!$acc parallel loop async
do concurrent (j=1:N)
a(j) = j
end do
b = foo()
#pragma acc wait
c = sum(a) + b
在之前的示例中,数组 a
在 DC 循环中以异步方式填充值。
8.3.4. 与 CUDA Fortran 的互操作性
编译用于 GPU 的 Stdpar 代码时,也可以使用 CUDA Fortran 功能。要识别源代码中的 CUDA Fortran 功能,请使用 -cuda
命令行标志和 nvfortran
进行编译。
nvfortran -stdpar -cuda example.f90
在几种情况下,使用 CUDA Fortran 扩展可以增强 do concurrent (DC) 循环和 Stdpar 程序的功能
显式数据局部性,从 DC 循环内部访问带有 device、managed、unified 或 constant 属性的 CUDA Fortran 属性数组或其他数据。
调整 DC 循环在 GPU 上的执行,例如控制 GPU 内核启动配置。
使用特定的 CUDA 流异步执行 DC 循环。
从 DC 循环内部调用外部的、用户定义的 CUDA 设备例程。
在 DC 循环中使用 CUDA 原子操作,或其他 CUDA 特定的设备端运行时库调用。
在 DC 循环外部插入 CUDA 运行时 API 调用,以进行内存调整提示。
示例
下面提供了一些将 CUDA Fortran 功能与 DC 循环结合使用的示例。以下示例演示了 DC 循环如何访问 CUDA Fortran 设备数据,在特定的 CUDA 流上运行,调用 CUDA 运行时 API 来创建流,以及如何将非标准功能隐藏在 CUF 哨兵之后以实现代码可移植性。
!@cuf use cudafor
!@cuf integer(kind=cuda_stream_kind) :: istrm
real, allocatable :: a(:,:), b(:,:)
!@cuf attributes(device) :: a ! A is device array only, not unified/managed
. . .
!@cuf istat = cudaStreamCreate(istrm)
. . .
a(:,:) = 0.0
. . .
!$cuf kernel do(1) <<< *, *, stream=istrm>>>
do concurrent (j=1:N)
do i=1,K
a(j,i) = a(j,i) + 2.0 * b(j,i)
end do
end do
此程序演示了如何从 DC 循环内部调用底层 CUDA 设备函数。该函数可以使用 CUDA Fortran 或 CUDA C++ 编写,具体取决于接口。CUDA C 函数必须针对可重定位的设备代码进行编译。这可以用于访问基于指令的模型或标准语言中不易获得的 CUDA 和 NVIDIA GPU 中的功能。
module mcuda
contains
attributes(host,device) pure integer function std_dbg(itype)
integer, value :: itype
if (itype.eq.1) then
std_dbg = threadIdx%x
else if (itype.eq.2) then
std_dbg = blockIdx%x
else
std_dbg = (blockIdx%x-1)*blockDim%x + threadIdx%x
end if
end function
end module
program test
use mcuda
integer, parameter :: N = 2000
integer, allocatable :: a(:), b(:), c(:)
allocate(a(N),b(N),c(N))
do concurrent (j=1:N)
a(j) = std_dbg(1)
b(j) = std_dbg(2)
c(j) = std_dbg(3)
end do
print *,a(1),a(N/2),a(N)
print *,b(1),b(N/2),b(N)
print *,c(1),c(N/2),c(N)
end
CUDA Fortran cudadevice
模块中的许多函数都可以在 do concurrent 循环中使用,而不仅仅是原子操作。此代码片段显示了两种用法
real :: tmp(4), x, y
...
block; use cudadevice
do concurrent (i=1:K,j=1:N)
x = real(j) + a(i,j)
y = atomicAdd(b(1,j), x)
end do
do concurrent (j=1:N)
x = real(j)
tmp(1:4) = __ldca(a(1:4,j))
tmp(1:4) = tmp(1:4) + x
call __stwt(b(1:4,j), tmp)
end do
end block
9. PCAST
并行编译器辅助软件测试 (PCAST) 是一组 API 调用和编译器指令,可用于测试程序的正确性。当程序的某些部分映射到 GPU 上、使用新的或额外的编译器选项,或者程序本身发生更改时,程序生成的数值结果可能会出现偏差。PCAST 可以帮助您确定这些偏差的开始位置,并查明导致偏差的更改。它在其他情况下也很有用,包括使用新的库、确定并行执行是否安全,或者将程序从一个 ISA 或处理器类型移植到另一个 ISA 或处理器类型。
9.1. 概述
PCAST 比较可以通过两种方式执行。第一种方式是通过 pcast_compare
调用或指令将初始运行的数据保存到文件中。在您希望比较中间结果的应用程序中添加调用或指令。然后,执行程序以保存“黄金”结果,其中值已知是正确的。在程序后续运行时,相同的 pcast_compare 调用或指令会将计算出的中间结果与保存的“黄金”结果进行比较,并报告差异。
第二种方法与 NVIDIA OpenACC 实现结合使用,以将 GPU 计算与在 CPU 上运行的相同程序进行比较。在这种情况下,所有计算结构都在 CPU 和 GPU 上冗余执行。GPU 结果与 CPU 结果进行比较,并报告差异。这本质上类似于第一种情况,其中 CPU 计算的值被视为“黄金”结果。可以使用 autocompare
标志在数据区域结束时隐式完成 GPU 到 CPU 的比较,也可以使用 acc_compare
调用或指令在内核之后显式完成 GPU 到 CPU 的比较。
使用 autocompare 标志,OpenACC 区域将在 CPU 和 GPU 上冗余运行。在要将数据从设备下载到主机的 OpenACC 区域退出时,PCAST 会将 CPU 上计算的值与 GPU 中计算的值进行比较。使用 autocompare
或 acc_compare
完成的比较在内存中处理,并且不会将结果写入中间文件。
下表概述了可以与 PCAST 一起使用的受支持数据类型。短整型、整型、长整型和半精度数据类型不支持 ABS
、REL
、ULP
或 IEEE
选项;仅支持逐位比较。
对于浮点类型,PCAST 可以计算绝对差、相对差和单位最后一位的差。绝对差仅测量两个值之间差的绝对值(减法),即 abs(A-B)。相对差计算为值差 A-B 与前一个值 A 之间的比率;abs((A-B)/A)。单位最后精度(Unit-last place)是衡量两个值 A 和 B 之间最小距离的指标。设置 ULP
选项后,如果两个数字之间计算出的 ULP 大于某个阈值,PCAST 将会报告。
C/C++ 类型 |
Fortran 类型 |
ABS |
REL |
ULP |
IEEE |
---|---|---|---|---|---|
float |
real, real(4) |
是 |
是 |
是 |
是 |
double |
double precision, real(8) |
是 |
是 |
是 |
是 |
float _Complex |
complex, complex(4) |
是 |
是 |
是 |
是 |
double _Complex |
complex(8) |
是 |
是 |
是 |
是 |
- |
real(2) |
否 |
否 |
否 |
否 |
(无)符号 short |
integer(2) |
N/A |
N/A |
N/A |
N/A |
(无)符号 int |
integer, integer(4) |
N/A |
N/A |
N/A |
N/A |
(无)符号 long |
integer(8) |
N/A |
N/A |
N/A |
N/A |
9.2. 使用“黄金”文件的 PCAST
运行时调用 pcast_compare
突出显示连续程序运行之间的差异。它有两种操作模式,具体取决于默认情况下是否存在名为 pcast_compare.dat 的数据文件。如果该文件不存在,pcast_compare
会假定这是第一次“黄金”运行。它将创建该文件,并在每次调用 pcast_compare
时用计算出的数据填充该文件。如果该文件存在,pcast_compare
会假定这是测试运行。它将读取该文件,并将计算出的数据与文件中保存的数据进行比较。默认行为是将前 50 个差异视为可报告的错误,无论差异多么小。
默认情况下,pcast_compare.dat
文件与可执行文件位于同一目录中。pcast_compare
的行为和其他比较参数可以在运行时使用 PCAST_COMPARE 环境变量进行更改,这在 环境变量 部分中讨论。
C++ 和 C 的 pcast_compare
的签名是
void pcast_compare(void*, char*, size_t, char*, char*, char*, int);
Fortran 的 pcast_compare
的签名是
subroutine pcast_compare(a, datatype, len, varname, filename, funcname, lineno)
type(*), dimension(..) :: a
character(*) :: datatype, varname, filename, funcname
integer(8),value :: len
integer(4),value :: lineno
该调用接受七个参数
要保存或比较的数据的地址。
包含数据类型的字符串。
要比较的元素数量。
被视为变量名的字符串。
被视为源文件名的字符串。
被视为函数名的字符串。
被视为行号的整数。
例如,可以像下面这样调用 pcast_compare
运行时调用
pcast_compare(a, "float", N, "a", "pcast_compare03.c", "main", 1);
call pcast_compare(a, 'real', n, 'a', 'pcast_compare1.f90', 'program', 9)
调用者应为最后四个参数提供有意义的名称。它们可以是任何内容,因为它们仅用于注释报告。至关重要的是,在比较之间不要修改标识符;每次程序运行时都必须以相同的顺序调用比较。例如,如果您在循环内部调用 pcast_compare
,则将最后一个参数设置为循环索引是合理的。
还存在 pcast_compare
的指令形式,它在功能上与运行时调用相同。它可以在程序中的任何点使用,以将数据的当前值与黄金文件中记录的值进行比较,与运行时调用相同。与 API 调用相比,使用指令有两个好处
指令语法比 API 语法简单得多。比较调用需要输出给用户的大部分数据可以通过编译器在编译时收集(类型、变量名、文件名、函数名和行号)。
#pragma nvidia compare(a[0:n])
相对于
pcast_compare(a, "float", N, "a", "pcast_compare03.c", "main", 1);
指令仅在设置 -Mpcast 标志时启用,因此完成测试后无需更改源。考虑以下用法示例
#pragma nvidia compare(a[0:N]) // C++ and C !$nvf compare(a(1:N)) ! Fortran
指令接口在下面以 C++ 或 C 风格以及 Fortran 给出。请注意,对于 Fortran,var-list
是变量名、子数组规范、数组元素或复合变量成员。
#pragma nvidia compare (var-list) // C++ and C
!$nvf compare (var-list) ! Fortran
让我们看一个例子
#include <stdlib.h>
#include <openacc.h>
int main() {
int size = 1000;
int i, t;
float *a1;
float *a2;
a1 = (float*)malloc(sizeof(float)*size);
a2 = (float*)malloc(sizeof(float)*size);
for (i = 0; i < size; i++) {
a1[i] = 1.0f;
a2[i] = 2.0f;
}
for (t = 0; t < 5; t++) {
for(i = 0; i < size; i++) {
a2[i] += a1[i];
}
pcast_compare(a2, "float", size, "a2", "example.c", "main", 23);
}
return 0;
}
使用以下编译器选项编译示例
$ nvc -fast -o a.out example.c
使用 redundant 或 autocompare 选项编译不是使用 pcast_compare 的必需条件。再次,使用以下选项运行编译后的可执行文件,将产生以下输出
$ PCAST_COMPARE=summary,rel=1 ./out.o
datafile pcast_compare.dat created with 5 blocks, 5000 elements, 20000 bytes
$ PCAST_COMPARE=summary,rel=1 ./out.o
datafile pcast_compare.dat compared with 5 blocks, 5000 elements, 20000 bytes
no errors found
relative tolerance = 0.100000, rel=1
首次运行程序时,将创建数据文件“pcast_compare.dat”。后续运行会将计算出的数据与此文件进行比较。使用 PCAST_COMPARE
环境变量设置文件名,或强制程序在磁盘上使用 PCAST_COMPARE=create
创建新文件。
上面的相同示例可以使用 compare 指令编写。请注意,该指令对于更新主机和 pcast_compare
调用而言是多么简洁。
#include <stdlib.h>
#include <openacc.h>
int main() {
int size = 1000;
int i, t;
float *a1;
float *a2;
a1 = (float*)malloc(sizeof(float)*size);
a2 = (float*)malloc(sizeof(float)*size);
for (i = 0; i < size; i++) {
a1[i] = 1.0f;
a2[i] = 2.0f;
}
for (t = 0; t < 5; t++) {
for(i = 0; i < size; i++) {
a2[i] += a1[i];
}
#pragma nvidia compare(a2[0:size])
}
return 0;
}
使用指令时,您需要将“-Mpcast”添加到编译行以启用该指令。除此之外,此程序的输出与上面的运行时示例相同。
9.3. 使用 OpenACC 的 PCAST
PCAST 也可以与 NVIDIA OpenACC 实现一起使用,以将 GPU 计算与在 CPU 上运行的相同程序进行比较。在这种情况下,所有计算结构都在 CPU 和 GPU 上冗余执行。CPU 结果被视为“黄金母版”副本,GPU 结果将与之进行比较。
有两种方法可以执行 GPU 计算结果的比较。第一种是使用显式调用或指令 acc_compare
。要使用 acc_compare
,您必须使用 -acc -gpu=redundant
进行编译,以强制 CPU 和 GPU 冗余计算结果。然后,在您想要将 GPU 计算的值与 CPU 计算的值进行比较的点插入对 acc_compare
的调用,或放置 acc compare
指令。
第二种方法是通过使用 -acc -gpu=autocompare
进行编译来启用 autocompare 模式。在 autocompare 模式下,PCAST 将在每次数据从设备移动到主机时自动执行比较。它不需要程序员添加任何额外的指令或运行时调用;这是一种在数据区域末尾执行所有比较的便捷方法。如果数据区域内有多个计算内核,并且您只对一个特定的内核感兴趣,则应使用前面提到的 acc_compare
来定位特定的内核。请注意,autocompare 模式意味着 -gpu=redundant
。
在冗余执行期间,编译器将为每个计算结构生成 CPU 和 GPU 代码。在运行时,CPU 和 GPU 版本都将冗余执行,CPU 代码读取和修改系统内存中的值,而 GPU 读取和修改设备内存中的值。在您想要将 GPU 计算的值与 CPU 计算的值进行比较的点插入对 acc_compare()
的调用(或等效的 acc compare
指令)。PCAST 将 CPU 代码生成的值视为“黄金”值。它会将这些结果与 GPU 值进行比较。与 pcast_compare
不同,acc_compare
不会写入中间文件;比较是在内存中完成的。
acc_compare
只有两个参数:指向要比较的数据的指针 hostptr 和要比较的元素数量 count。类型可以在 OpenACC 运行时中推断出来,因此无需指定。下面给出 C++ 和 C 接口
void acc_compare(void *, size_t);
以及 Fortran 中的接口
subroutine acc_compare(a)
subroutine acc_compare(a, len)
type(*), dimension(*) :: a
integer(8), value :: len
您可以对设备内存中存在的任何变量或数组调用 acc_compare
。您还可以调用 acc_compare_all
(无参数)来比较设备内存中存在的所有值与主机内存中的相应值。
void acc_compare_all()
subroutine acc_compare_all()
存在 acc_compare
调用的指令形式。它们的工作方式与 API 调用相同,并且可以代替 API 调用使用。与 PCAST compare
指令类似,当编译行中未启用 redundant 或 autocompare 模式时,acc compare
指令将被忽略。
acc compare
指令接受一个或多个参数,或 ‘all’ 子句(对应于 acc_compare_all()
)。接口分别在下面的 C++ 或 C 以及 Fortran 中给出。参数 “var-list” 可以是变量名、子数组规范、数组元素或复合变量成员。
#pragma acc compare [ (var-list) | all ]
$!acc compare [ (var-list) | all ]
例如
#pragma acc compare(a[0:N])
#pragma acc compare all
!$acc compare(a, b)
!$acc compare(a(1:N))
!$acc compare all
考虑以下 OpenACC 程序,该程序使用 acc_compare()
API 调用和 acc compare
指令。此 Fortran 示例使用 real*4 和 real*8 数组。
program main
use openacc
implicit none
parameter N = 1000
integer :: i
real :: a(N)
real*4 :: b(N)
real(4) :: c(N)
double precision :: d(N)
real*8 :: e(N)
real(8) :: f(N)
d = 1.0d0
e = 0.1d0
!$acc data copyout(a, b, c, f) copyin(d, e)
!$acc parallel loop
do i = 1,N
a(i) = 1.0
b(i) = 2.0
c(i) = 0.0
enddo
!$acc end parallel
!$acc compare(a(1:N), b(1:N), c(1:N))
!$acc parallel loop
do i = 1,N
f(i) = d(i) * e(i)
enddo
!$acc end parallel
!$acc compare(f)
!$acc parallel loop
do i = 1,N
a(i) = 1.0
b(i) = 1.0
c(i) = 1.0
enddo
!$acc end parallel
call acc_compare(a, N)
call acc_compare(b, N)
call acc_compare(c, N)
!$acc parallel loop
do i = 1,N
f(i) = 1.0D0
enddo
!$acc end parallel
call acc_compare_all()
!$acc parallel loop
do i = 1,N
a(i) = 3.14;
b(i) = 3.14;
c(i) = 3.14;
f(i) = 3.14d0;
enddo
!$acc end parallel
! In redundant mode, no comparison is performed here. In
! autocompare mode, a comparison is made for a, b, c, and f (but
! not e and d), since they are copied out of the data region.
!$acc end data
call verify(N, a, b, c, f)
end program
subroutine verify(N, a, b, c, f)
integer, intent(in) :: N
real, intent(in) :: a(N)
real*4, intent(in) :: b(N)
real(4), intent(in) :: c(N)
real(8), intent(in) :: f(N)
integer :: i, errcnt
errcnt = 0
do i=1,N
if(abs(a(i) - 3.14e0) .gt. 1.0e-06) then
errcnt = errcnt + 1
endif
end do
do i=1,N
if(abs(b(i) - 3.14e0) .gt. 1.0e-06) then
errcnt = errcnt + 1
endif
end do
do i=1,N
if(abs(c(i) - 3.14e0) .gt. 1.0e-06) then
errcnt = errcnt + 1
endif
end do
do i=1,N
if(abs(f(i) - 3.14d0) .gt. 1.0d-06) then
errcnt = errcnt + 1
endif
end do
if(errcnt /= 0) then
write (*, *) "FAILED"
else
write (*, *) "PASSED"
endif
end subroutine verify
该程序可以使用以下命令编译
$ nvfortran -fast -acc -gpu=redundant -Minfo=accel example.F90
main:
16, Generating copyout(a(:),b(:))
Generating copyin(e(:))
Generating copyout(f(:),c(:))
Generating copyin(d(:))
18, Generating Tesla code
19, !$acc loop gang, vector(128) ! blockidx%x threadidx%x
26, Generating acc compare(c(:),b(:),a(:))
28, Generating Tesla code
29, !$acc loop gang, vector(128) ! blockidx%x threadidx%x
34, Generating acc compare(f(:))
36, Generating Tesla code
37, !$acc loop gang, vector(128) ! blockidx%x threadidx%x
48, Generating Tesla code
49, !$acc loop gang, vector(128) ! blockidx%x threadidx%x
56, Generating Tesla code
57, !$acc loop gang, vector(128) ! blockidx%x threadidx%x
在这里,您可以看到 acc compare 指令在第 26 行和第 34 行生成。该程序可以使用以下命令运行
$ ./a.out
PASSED
如您所见,当比较匹配时,不会生成 PCAST 输出。我们可以使用 summary 选项获取更多信息
$ PCAST_COMPARE=summary ./a.out
PASSED
compared 13 blocks, 13000 elements, 68000 bytes
no errors found
absolute tolerance = 0.00000000000000000e+00, abs=0
总共比较了 13 个块。让我们计算一下 compare 调用中的块数。
!$acc compare(a(1:N), b(1:N), c(1:N))
为 a、b 和 c 各比较一个块,共三个块。
!$acc compare(f)
为 f 比较一个块。
call acc_compare(a, N)
call acc_compare(b, N)
call acc_compare(c, N)
每个调用都为其各自的数组比较一个块。
call acc_compare_all()
为设备上存在的每个数组(a、b、c、d、e 和 f)比较一个块,总共 6 个块。
如果使用 autocompare 编译相同的示例,我们将看到额外的四个比较,因为在数据区域末尾比较了复制出的(带有 copyout 子句的)四个数组。
$ nvfortran -fast -acc -gpu=autocompare example.F90
$ PCAST_COMPARE=summary ./a.out
PASSED
compared 17 blocks, 17000 elements, 88000 bytes
no errors found
absolute tolerance = 0.00000000000000000e+00, abs=0
9.4. 局限性
目前,使用 PCAST 存在一些值得注意的局限性。
比较不是线程安全的。如果您在多线程中使用 PCAST,请确保只有一个线程在执行比较。如果您将 PCAST 与 MPI 一起使用,则尤其如此。如果您将
pcast_compare
与 MPI 一起使用,则必须确保只有一个线程正在写入比较文件。或者,使用脚本将 PCAST_COMPARE 设置为使用 MPI 秩对文件名进行编码。比较必须使用相似的类型进行;您不能将一种类型与另一种类型进行比较。例如,不可能在从双精度更改为单精度后检查不同的结果。比较仅限于表 表 23 中存在的类型。目前不支持结构化类型或派生类型。
-gpu=mem:managed
或-gpu=mem:unified
选项与 autocompare 和acc_compare
不兼容。CPU 和 GPU 都需要分别计算结果,并且为此,它们必须拥有自己的工作内存空间。如果您在设备上执行任何数据移动,则必须在主机上对其进行说明。例如,如果您正在使用 CUDA 感知的 MPI 或 GPU 加速库来修改设备数据,则还必须使主机意识到这些更改。在这些情况下,使用
host_data
子句很有帮助,该子句允许您在主机代码中使用设备地址。
9.5. 环境变量
PCAST/Autocompare 的行为通过 PCAST_COMPARE
变量控制。选项可以在逗号分隔的列表中指定:PCAST_COMPARE=<opt1>,<opt2>,...
如果未指定任何选项,则默认设置为执行 abs=0 的比较。比较选项不是互斥的。PCAST 可以将绝对差与某个 n=3 进行比较,并将相对差与不同的阈值进行比较,例如 n=5;PCAST_COMPARE=abs=3,rel=5,…。
您可以指定绝对位置或相对位置以与 datafile 选项一起使用。父目录应归执行比较的同一用户所有,并且数据文件应具有适当的读/写权限集。
选项 |
描述 |
---|---|
|
比较绝对差;容忍高达 10^(-n) 的差异,仅适用于浮点类型。默认值为 0 |
|
指定这是将生成参考文件的运行(仅限 |
|
指定当前运行将与参考文件进行比较(仅限 |
|
数据将保存到或与之比较的文件的名称。如果为空,将使用默认值 |
|
对 |
|
比较 IEEE NaN 检查(仅针对 float 和 double 实现) |
|
将比较输出保存到特定文件。默认行为是输出到 stderr |
|
使用正确的值修补错误(超出容差) |
|
使用正确的值修补所有差异(在容差范围内和超出容差范围) |
|
比较相对差;容忍高达 10^(-n) 的差异,仅适用于浮点类型。默认值为 0。 |
|
报告最多 n 个(默认为 50 个)通过/失败 |
|
报告所有通过和失败(覆盖 report=n 中设置的限制) |
|
报告通过;遵守使用 report=n 设置的限制 |
|
抑制输出 - 覆盖所有其他输出选项,包括 summary 和 verbose |
|
在第一个差异处停止 |
|
在运行结束时打印比较摘要 |
|
比较单位最后精度差(仅适用于 float 和 double) |
|
输出更多比较详细信息(包括修补) |
|
输出主机正在比较的内容和位置的详细报告(仅限 autocompare) |
10. 使用 MPI
MPI(消息传递接口)是一种行业标准应用程序编程接口,专为分布式内存环境中处理器之间的快速数据交换而设计。MPI 是可扩展计算机系统中使用的计算机软件,它允许并行应用程序的进程彼此通信。
NVIDIA HPC SDK 包括 Open MPI 的预编译版本。您可以使用 -I
、-L
和 -l
选项使用 MPI 的替代版本进行构建。
本节介绍如何将 Open MPI 与 NVIDIA HPC 编译器一起使用。
10.1. 在 Linux 上使用 Open MPI
适用于 Linux 的 NVIDIA HPC 编译器附带了 Open MPI 的预编译版本,其中包含使用 Open MPI 编译、执行和调试 MPI 程序所需的一切内容。
要使用 Open MPI 构建应用程序,请使用 Open MPI 编译器包装器:mpicc
、mpic++
和 mpifort
。这些包装器会自动使用正确的包含文件搜索路径、库目录和链接库来设置编译器命令。
以下 MPI 示例程序使用 Open MPI。
$ cd my_example_dir $ cp -r /opt/nvidia/hpc_sdk/Linux_x86_64/25.1/examples/MPI/samples/mpihello . $ cd mpihello $ export PATH=/opt/nvidia/hpc_sdk/Linux_x86_64/25.1/mpi/openmpi/bin:$PATH $ mpifort mpihello.f -o mpihello
$ mpiexec mpihello
Hello world! I'm node 0
$ mpiexec -np 4 mpihello
Hello world! I'm node 0
Hello world! I'm node 2
Hello world! I'm node 1
Hello world! I'm node 3
要使用 Open MPI 构建用于调试的应用程序,请将 -g 添加到编译器包装器命令行参数。
10.2. 使用 MPI 编译器包装器
当您使用 MPI 编译器包装器并使用 -fpic
或 -mcmodel=medium
选项进行构建时,则必须指定 -fortranlibs
以链接到正确的库。以下是一些示例
对于 MPI 库的静态链接,请使用以下命令
$ mpifort hello.f
对于 MPI 库的动态链接,请使用以下命令
$ mpifort hello.f -fortranlibs
要使用 -fpic
进行编译,默认情况下,这将调用动态链接,请使用以下命令
$ mpifort -fpic -fortranlibs hello.f
要使用 -mcmodel=medium
进行编译,请使用以下命令
$ mpifort -mcmodel=medium -fortranlibs hello.f
10.3. 测试和基准测试
/opt/nvidia/hpc_sdk/Linux_x86_64/25.1/examples/MPI 目录包含各种基准测试和测试。通过发出以下命令将此目录复制到本地工作目录中
text % cp -r /opt/nvidia/hpc_sdk/Linux_x86_64/25.1/examples/MPI .
此目录中有几个示例程序可用。
11. 创建和使用库
库是为方便参考和链接而分组的功能或子程序的集合。本节讨论与 NVIDIA 提供的编译器库相关的问题。具体来说,它讨论了使用 C++ 和 C 内置函数代替相应的 libc 例程、创建动态链接库(称为共享对象或共享库)以及数学库。
注意
本节不重复与用于内联的库相关的内容,这些内容在 创建内联库 中进行了描述。
NVIDIA 提供了通过使用 Fortran 模块导出 C 接口的库。
11.1. 在 C++ 和 C 中使用内置数学函数
数学头文件的名称是 math.h
。在所有使用数学库例程的源文件中包含数学头文件,如以下示例所示,该示例计算 3.5 的反余弦。
#include <math.h>
#include <stdio.h>
#define PI 3.1415926535
void main()
{
double x, y;
x = PI/3.0;
y = acos(0.5);
printf('%f %f\n',x,y);
}
包含 math.h
会导致 NVIDIA C++ 和 C 编译器使用内置函数,这些函数比库调用效率高得多。特别是,如果您包含 math.h
,则以下内部函数调用将使用内置函数进行处理
abs |
acosf |
asinf |
atan |
atan2 |
atan2f |
atanf |
cos |
cosf |
exp |
expf |
fabs |
fabsf |
fmax |
fmaxf |
fmin |
fminf |
log |
log10 |
log10f |
logf |
pow |
powf |
sin |
sinf |
sqrt |
sqrtf |
tan |
tanf |
11.2. 使用系统库例程
NVIDIA HPC 编译器运行时库的 25.1 版本利用 Linux 系统库来实现,例如 OpenMP 和 Fortran I/O。NVIDIA HPC 编译器运行时库使用了几个额外的系统库例程。
在 64 位 Linux 系统上,使用的系统库例程包括以下这些
aio_error |
aio_write |
pthread_mutex_init |
sleep |
aio_read |
calloc |
pthread_mutex_lock |
|
aio_return |
getrlimit |
pthread_mutex_unlock |
|
aio_suspend |
pthread_attr_init |
setrlimit |
11.4. 使用 LIB3F
NVFORTRAN 编译器包括对事实标准 LIB3F 库例程的支持。有关 NVIDIA LIB3F 实现中可用例程的完整列表,请参阅《Fortran 语言参考手册》。
11.5. LAPACK、BLAS 和 FFT
NVIDIA HPC SDK 包含一个基于定制的 OpenBLAS 项目源代码并使用 NVIDIA HPC 编译器构建的 BLAS 和 LAPACK 库。 LAPACK 库名为 liblapack.a
。 BLAS 库名为 libblas.a
。
要使用这些库,只需在链接主程序时使用 -l
选项将它们链接进来
% nvfortran myprog.f -llapack -lblas
11.6. 链接 ScaLAPACK
ScaLAPACK 库与每个随 NVIDIA HPC SDK 安装的 MPI 库版本一起自动安装。您可以通过在任何 MPI 包装器命令行上指定 -Mscalapack
来链接 ScaLAPACK 库。 例如
% mpifort myprog.f -Mscalapack
当指定 -Mscalapack
开关时,会自动添加预构建版本的 BLAS 库。 如果您希望使用不同的 BLAS 库,并且仍然使用 -Mscalapack
开关,则可以在链接行中显式列出库集。
如果在 -Mscalapack
之外还指定了 -Mnvpl
开关,则将使用 NVPL ScaLAPACK 库。
11.7. C++ 标准模板库
在 Linux 上,GNU 兼容的 nvc++ 编译器直接使用 GNU g++ 头文件和标准模板库 (STL)。使用的版本取决于系统上安装的 GNU 编译器的版本,或在 NVIDIA HPC 编译器的安装期间运行 makelocalrc 时指定的版本。
11.8. NVIDIA 性能库 (NVPL)
NVIDIA 性能库 (NVPL) 是一套针对 NVIDIA Grace Arm 架构优化的高性能数学库套件。这些仅限 CPU 的库不依赖 CUDA 或 CTK,并且可以作为标准 C 和 Fortran 数学 API 的直接替代品,使 HPC 应用程序能够在 Grace 平台上实现最佳性能。它们仅适用于 Arm CPU。 NVPL 包括以下数学库:BLAS、FFT、LAPACK、RAND、ScaLAPACK、Sparse 和 Tensor。 有关这些数学库的更多信息,请参阅 NVPL 文档。以下部分介绍了如何将它们与 NVHPC 编译器一起使用。
要使用 NVPL 库,请在链接主程序时使用 -Mnvpl
选项
% nvfortran myprog.f -Mnvpl
您可以使用 -Mnvpl
的子选项仅链接应用程序需要的 NVPL 库。例如,如果您只想要 NVPL 中的 BLAS 和 FFT 库,请按如下方式链接
% nvfortran myprog.f -Mnvpl=blas,fft
有关 -Mnvpl
标志支持的选项的完整列表,请参阅《NVIDIA HPC 编译器参考指南》。
ScaLAPACK
与其他 ScaLAPACK 库类似,NVPL 版本旨在与 MPI 一起使用。访问 NVPL ScaLAPACK 库的直接方法是使用 MPI 包装器(即,mpicc
、mpic++
、mpifort
)并同时与 -Mnvpl
和 -Mscalapack
链接。 例如
% mpic++ myprog.cpp -Mscalapack -Mnvpl
如果您选择不使用 MPI 包装器,则可以通过在链接时显式提供 libmpi.so 库来满足 ScaLAPACK 对 libmpi.so 的依赖性。
NVPL ScaLAPACK 接口可用于以下 MPI 变体:MPICH、Open MPI 3.x、Open MPI 4.x(包括 HPC-X)和 Open MPI 5.x。 HPC SDK 包含 Open MPI 3、Open MPI 4 和 HPC-X 的构建版本;要利用 NVPL 的 MPICH 或 Open MPI 5.x 的 ScaLAPACK 接口,您必须提供您自己构建的这些 MPI 库。
11.9. 链接 nvmalloc 库
NVIDIA HPC SDK 安装包含一个基于 jemalloc 内存分配器的自定义主机(系统)内存分配库。此库替换了 nvc、nvc++ 和 nvfortran 运行时用于动态堆分配的系统 malloc()、free() 和其他相关函数。您可以通过在用于链接的任何编译器命令行上指定 -nvmalloc 来链接此库。 例如
% nvc main.c -nvmalloc
12. 环境变量
环境变量允许您设置和传递可以更改 NVIDIA HPC 编译器及其生成的可执行文件的默认行为的信息。本节包括 NVIDIA HPC 编译器特有的环境变量的说明。
标准 OpenMP 环境变量用于控制 OpenMP 程序的行为;这些环境变量在可在线获取的 OpenMP 规范中进行了描述。
几个 NVIDIA 特定的环境变量可用于控制 OpenACC 程序的行为。OpenACC 相关的环境变量在 OpenACC 部分中进行了描述:环境变量 和 OpenACC 入门指南。
12.1. 设置环境变量
在我们查看您可能与 HPC 编译器和工具一起使用的环境变量之前,让我们先看一下如何设置环境变量。为了说明如何在各种环境中设置这些变量,让我们看一下用户如何初始化 Linux shell 环境以启用 NVIDIA HPC 编译器的使用。
12.1.1. 在 Linux 上设置环境变量
假设您希望在登录时访问 NVIDIA 产品,并且您已将 NVIDIA HPC SDK 安装在 /opt/nvidia/hpc_sdk 中。为了在启动时进行访问,您可以将以下行添加到 Linux_x86_64 系统上的 shell 启动文件中。
对于 csh,使用以下命令
$ setenv NVHPCSDK /opt/nvidia/hpc_sdk $ setenv MANPATH "$MANPATH":$NVHPCSDK/Linux_x86-64/25.1/compilers/man $ set path = ($NVHPCSDK/Linux_x86_64/25.1/compilers/bin $path)
对于 bash、sh、zsh 或 ksh,使用以下命令
$ NVHPCSDK=/opt/nvidia/hpc_sdk; export NVHPCSDK $ MANPATH=$MANPATH:$NVHPCSDK/Linux_x86_64/25.1/compilers/man; export MANPATH $ PATH=$NVHPCSDK/Linux_x86_64/25.1/compilers/bin:$PATH; export PATH
在 Linux/Arm Server 系统上,将 Linux_x86_64
替换为 Linux_aarch64
。
12.3. HPC 编译器环境变量
使用 表 25 中列出的环境变量来更改 NVIDIA HPC 编译器及其生成的可执行文件的默认行为。本节提供有关此表中的变量的更详细说明。
12.3.1. FORTRANOPT
FORTRANOPT
允许用户调整 NVIDIA Fortran 编译器的行为。
如果
FORTRANOPT
存在并且包含值vaxio
,则 open 语句中的记录长度以 4 字节字为单位,并且 $ 编辑描述符仅对以空格或加号 (+) 开头的行有效。如果
FORTRANOPT
存在并且包含值format_relaxed
,则与数值编辑描述符(例如 F、E、I 等)对应的 I/O 项不需要是描述符隐含的类型。如果
FORTRANOPT
存在并且包含值no_minus_zero
,则与数值编辑描述符(例如 F、E、I 等)对应的 I/O 项等于负零将输出为正零。如果
FORTRANOPT
存在并且包含值crif
,则允许顺序格式化或列表定向的记录以字符序列\\r\\n
(回车符,换行符)结尾。当从 Windows 系统上生成的文件中读取记录时,此方法很有用。
以下示例使 NVIDIA Fortran 编译器使用 VAX I/O 约定
$ setenv FORTRANOPT vaxio
12.3.2. FORT_FMT_RECL
FORT_FMT_RECL
环境变量指定 Fortran 格式化输出到标准输出(单元 6)的最大行字节数,超过此字节数将生成换行符。
如果环境变量 FORT_FMT_RECL
存在,则 Fortran 运行时库将使用指定的值作为生成换行符之前要输出的字节数。
FORT_FMT_RECL
的默认值为 80。
在 csh 中
$ setenv FORT_FMT_RECL length-in-bytes
在 bash、sh、zsh 或 ksh 中
$ FORT_FMT_RECL=length-in-bytes $ export FORT_FMT_RECL
12.3.3. GMON_OUT_PREFIX
GMON_OUT_PREFIX
指定使用 -pg
选项编译和链接的程序的输出文件的名称。默认名称为 gmon.out
。
如果设置了 GMON_OUT_PREFIX
,则输出文件的名称以 GMON_OUT_PREFIX
作为前缀。此外,后缀是正在运行的进程的 pid。前缀和后缀用点分隔。例如,如果输出文件为 mygmon
,则完整文件名可能类似于:mygmon.0012348567
。
以下示例使 NVIDIA Fortran 编译器使用 nvout
作为使用 -pg
选项编译和链接的程序的输出文件。
$ setenv GMON_OUT_PREFIX nvout
12.3.4. LD_LIBRARY_PATH
LD_LIBRARY_PATH
变量是一个以冒号分隔的目录集,指定库应首先在其中搜索,然后再搜索标准目录集。当调试新库或将非标准库用于特殊目的时,此变量很有用。
以下 csh 示例将当前目录添加到您的 LD_LIBRARY_PATH
变量。
$ setenv LD_LIBRARY_PATH "$LD_LIBRARY_PATH":"./"
12.3.5. MANPATH
MANPATH
变量设置搜索与用户键入的命令关联的手册页的目录。使用 NVIDIA HPC 编译器时,重要的是设置您的 PATH
以包含编译器的位置,然后设置 MANPATH
变量以包含与产品关联的手册页。
以下 csh 示例以 Linux_x86_64 版本的编译器为目标,并启用对手册页的访问。Linux_aarch64 目标的设置类似
$ set path = (/opt/nvidia/hpc_sdk/Linux_x86_64/25.1/compilers/bin $path) $ setenv MANPATH "$MANPATH":/opt/nvidia/hpc_sdk/Linux_x86_64/25.1/compilers/man
12.3.6. NO_STOP_MESSAGE
如果 NO_STOP_MESSAGE
变量存在,则执行普通 STOP
语句不会产生消息 FORTRAN STOP。NVIDIA Fortran 编译器的默认行为是发出此消息。
12.3.7. PATH
PATH
变量确定搜索用户键入的命令的目录。使用 NVIDIA HPC 编译器时,重要的是设置您的 PATH
以包含编译器的位置。
以下 csh 示例初始化路径设置以使用 Linux_x86_64 版本的 NVIDIA HPC 编译器。Linux_aarch64 的设置类似
$ set path = (/opt/nvidia/hpc_sdk/Linux_x86_64/25.1/compilers/bin $path)
12.3.8. NVCOMPILER_FPU_STATE
NVCOMPILER_FPU_STATE
环境变量管理处理器浮点控制和状态寄存器的初始状态。 NVCOMPILER_FPU_STATE
消除了使用 -M[no]daz
、-M[no]flushz
或 -Ktrap=
命令行选项编译程序的主入口点 (c/c++/Fortran) 的需要,因为现在可以在运行时指定这些选项。
注意
仅限 Linux
如果环境变量 NVCOMPILER_FPU_STATE 存在,则来自命令行选项 -M[no]daz
、-M[no]flushz
或 -Ktrap=
的所有设置都将被忽略,并且 FPU 将根据指定的选项进行初始化。 没有选项的 NVCOMPILER_FPU_STATE 将浮点控制和状态寄存器重置为系统默认值。
NVCOMPILER_FPU_STATE
的值是以逗号分隔的选项列表。以下是用于设置环境变量的命令。
在 csh 中
$ setenv NVCOMPILER_FPU_STATE option[,option...]
在 bash、sh、zsh 或 ksh 中
$ NVCOMPILER_FPU_STATE=option[,option...] $ export NVCOMPILER_FPU_STATE
表 26 列出了 option
的支持值。
默认情况下,这些选项取自编译器命令行选项 -M[no]daz
、-M[no]flushz
和 -Ktrap=
。
|
inv、divz、ovf 的简写 |
|
在浮点无效操作(无穷大 - 无穷大、无穷大 / 无穷大、0 / 0、…)时引发异常 |
|
inv 的别名 |
|
使用浮点非规范化操作数引发异常(仅限 x86_64) |
|
在浮点除以零时引发异常 |
|
divz 的别名 |
|
在结果中浮点溢出时引发异常 |
|
ovf 的别名 |
|
在结果中浮点下溢时引发异常 |
|
unf 的别名 |
|
在浮点不精确结果时引发异常 |
|
将非规范化源操作数转换为零 |
|
不要将非规范化源操作数转换为零 |
|
将下溢结果刷新为零 |
|
ftz 的别名 |
|
不要将下溢结果刷新为零 |
|
noftz 的别名 |
|
在处理环境变量 |
|
print 的别名 |
12.3.9. NVCOMPILER_TERM
NVCOMPILER_TERM
环境变量控制堆栈回溯和即时调试功能。运行时库使用 NVCOMPILER_TERM
的值来确定程序异常终止时要采取的操作。
NVCOMPILER_TERM
的值是以逗号分隔的选项列表。以下是用于设置环境变量的命令。
在 csh 中
$ setenv NVCOMPILER_TERM option[,option...]
在 bash、sh、zsh 或 ksh 中
$ NVCOMPILER_TERM=option[,option...] $ export NVCOMPILER_TERM
表 27 列出了 option
的支持值。表后是对每个选项的完整描述,其中具体说明了如何应用该选项。
默认情况下,所有这些选项均已禁用。
|
启用/禁用即时调试(在错误时调用调试) |
|
启用/禁用错误时的堆栈回溯 |
|
启用/禁用错误时的堆栈回溯和 SIMD 寄存器 (ymm/zmm) 打印(仅限 Linux x86_64) |
|
启用/禁用为导致程序终止的常见信号建立信号处理程序 |
|
启用/禁用调用系统终止例程 abort() |
[no]debug
[no]abort
这启用/禁用即时调试。默认值为 nodebug
。
[no]debug
当 NVCOMPILER_TERM
设置为 debug
时,NVCOMPILER_TERM_DEBUG
设置的命令将在错误时调用。
这启用/禁用错误时的堆栈回溯。
[no]trace-fp
[no]trace-fp
这启用/禁用错误时的堆栈回溯和 SIMD 寄存器 (ymm/zmm) 打印。(仅限 Linux x86_64)
[no]signal
这启用/禁用为导致程序终止的最常见信号建立信号处理程序。默认值为 nosignal
。设置 trace
和 debug
会自动启用 signal
。专门设置 nosignal
允许您覆盖此行为。
这启用/禁用调用系统终止例程 abort()。默认值为 noabort
。当 noabort
生效时,进程通过调用 _exit(127)
终止。
在 Linux 上,当 abort
生效时,abort 例程会创建一个核心文件并以代码 127 退出。
一些运行时错误只是打印错误消息并调用 exit(127)
,而与 NVCOMPILER_TERM
的状态无关。这些主要是错误,例如指定无效的环境变量值,其中回溯将不起作用。
如果看起来 abort() 在 Linux 系统上未生成核心文件,请务必取消对 coredumpsize 的限制。您可以通过以下方式执行此操作
$ limit coredumpsize unlimited $ setenv NVCOMPILER_TERM abort
使用 csh
$ ulimit -c unlimited $ export NVCOMPILER_TERM=abort
使用 bash、sh、zsh 或 ksh
$ gdb --core=core a.out
要使用 gdb 调试核心文件,请使用 –core 选项调用 gdb。例如,要查看名为 “a.out” 的程序的名为 “core” 的核心文件
有关为何使用此变量的更多信息,请参阅 堆栈回溯和 JIT 调试。
12.3.10. NVCOMPILER_TERM_DEBUG
当 NVCOMPILER_TERM
设置为 debug
时,可以设置 NVCOMPILER_TERM_DEBUG
变量以覆盖默认行为。
NVCOMPILER_TERM_DEBUG
的值应设置为用于调用程序的命令行。例如
… code:: text
gdb –quiet –pid %d
NVCOMPILER_TERM_DEBUG
字符串中第一次出现的 %d
将被进程 id 替换。在当前 PATH
上必须找到 NVCOMPILER_TERM_DEBUG
字符串中命名的程序,或者使用完整路径名指定。
12.3.11. PWD
PWD 变量允许您显示当前目录。
12.3.12. STATIC_RANDOM_SEED
您可以使用 STATIC_RANDOM_SEED
强制 Fortran 90/95 RANDOM_SEED
内部函数返回的种子为常量。第一次调用不带参数的 RANDOM_SEED
会将随机种子重置为默认值,然后根据时间将种子推进可变量。后续调用不带参数的 RANDOM_SEED
会将随机种子重置为与第一次调用相同的初始值。除非时间完全相同,否则每次运行程序都会生成不同的随机数序列。将环境变量 STATIC_RANDOM_SEED
设置为 YES
会强制 RANDOM_SEED
返回的种子为常量,从而在程序的每次执行中生成相同的随机数序列。
12.3.13. TMP
您可以使用 TMP
指定用于放置在 NVIDIA HPC 编译器执行期间创建的任何临时文件的目录。此变量可与 TMPDIR
互换。
12.3.14. TMPDIR
12.4. 在 Linux 上使用 Environment Modules
在 Linux 上,如果您使用 Environment Modules 包,即 module load
命令,NVIDIA HPC 编译器包含一个脚本来设置适当的模块文件。安装脚本将在设置过程中为您生成环境模块文件。
假设您的安装基础目录是 /opt/nvidia/hpc_sdk
,则环境模块将安装在 /opt/nvidia/hpc_sdk/modulefiles
下。将有三组模块文件:
nvhpc
为 NVIDIA HPC 编译器、CUDA 库和额外的库(如 MPI、NCCL 和 NVSHMEM)添加环境变量设置。
nvhpc-nompi
为 NVIDIA HPC 编译器、CUDA 库和额外的库(如 NCCL 和 NVSHMEM)添加环境变量设置。如果您希望使用备用的 MPI 实现,则这将不包括 MPI。
nvhpc-byo-compilers
为 CUDA 库和额外的库(如 NCCL 和 NVSHMEM)添加环境变量设置。如果您希望使用备用编译器和 MPI,则这将不包括 NVIDIA HPC 编译器和 MPI。
您可以按如下方式加载 20.11 版本的 nvhpc 环境模块
$ module load nvhpc/25.1
要查看此系统上可用的 nvhpc 版本,请使用以下命令
$ module avail nvhpc
module load
命令设置或修改环境变量,如下表所示。
此环境变量… |
由 module load 命令设置或修改 |
---|---|
|
nvc 的完整路径(仅限 nvhpc 和 nvhpc-nompi) |
|
前置数学库包含目录、MPI 包含目录(仅限 nvhpc)以及 NCCL 和 NVSHMEM 包含目录 |
|
C 预处理器,通常为 cpp(仅限 nvhpc 和 nvhpc-nompi) |
|
nvc++ 的路径(仅限 nvhpc 和 nvhpc-nompi) |
|
nvfortran 的完整路径(仅限 nvhpc 和 nvhpc-nompi) |
|
nvfortran 的完整路径(仅限 nvhpc 和 nvhpc-nompi) |
|
nvfortran 的完整路径(仅限 nvhpc 和 nvhpc-nompi) |
|
前置 CUDA 库目录、NVIDIA HPC 编译器库目录(仅限 nvhpc 和 nvhpc-nompi)、数学库库目录、MPI 库目录(仅限 nvhpc)以及 NCCL 和 NVSHMEM 库目录 |
|
前置 NVIDIA HPC 编译器 man 手册页目录(仅限 nvhpc 和 nvhpc-nompi) |
|
MPI 目录的完整路径(仅限 nvhpc),例如 /opt/nvidia/hpc_sdk/Linux_x86_64/25.1/comm_libs/mpi |
|
前置 CUDA bin 目录、MPI bin 目录(仅限 nvhpc)以及 NVIDIA HPC 编译器 bin 目录(仅限 nvhpc 和 nvhpc-nompi) |
注意
NVIDIA 不为 Environment Modules 包提供支持。有关此包的更多信息,请访问:http://modules.sourceforge.net。
12.5. 堆栈回溯和 JIT 调试
当编程错误导致运行时错误消息或应用程序异常时,程序通常会退出,可能还会显示错误消息。NVIDIA HPC 编译器运行时库包含一种机制,可以覆盖此默认操作,并改为打印堆栈回溯、启动调试器,或者在 Linux 上,创建核心文件以进行事后调试。
堆栈回溯和即时调试功能由环境变量 NVCOMPILER_TERM
控制,该变量在 NVCOMPILER_TERM 中描述。运行时库使用 NVCOMPILER_TERM
的值来确定程序异常终止时要采取的操作。
当 NVIDIA HPC 编译器运行时库检测到错误或捕获到信号时,它会在生成堆栈回溯或启动调试器之前调用例程 nvcompiler_stop_here()
。nvcompiler_stop_here()
例程是在调试程序时设置断点的便捷位置。
13. 分发文件 - 部署
一旦您成功构建、调试和调整了您的应用程序,您可能希望将其分发给需要在各种系统上运行它的用户。本节讨论如何有效地分发使用 NVIDIA HPC 编译器构建的应用程序。必须以某种方式安装应用程序,使其在与构建系统不同的系统上准确执行,并且该系统可能配置不同。
13.1. 在 Linux 上部署应用程序
为了在 Linux 上成功部署您的应用程序,需要考虑的一些问题包括
运行时库
64 位 Linux 系统
文件再分发
13.1.1. 运行时库注意事项
在 Linux 系统上,系统运行时库可以静态或动态地链接到应用程序。例如,对于 C 运行时库 libc
,您可以使用静态版本 libc.a
或共享对象版本 libc.so
。如果应用程序旨在在与构建系统不同的 Linux 系统上运行,则通常更安全地使用库的共享对象版本。这种方法确保应用程序使用与应用程序运行的系统兼容的库版本。此外,当应用程序在系统软件版本与应用程序将要运行的系统版本相同或更早的系统上链接时,效果最佳。
注意
在新系统上构建并在旧系统上运行应用程序可能无法产生期望的输出。
要使用库的共享对象版本,应用程序还必须链接到 NVIDIA HPC 编译器运行时库的共享对象版本。要在未安装 NVIDIA HPC 编译器的系统上执行以此方式构建的应用程序,这些共享对象必须可用。要使用运行时库的共享对象版本进行构建,请使用 -Bdynamic
选项,如下所示
$ nvfortran -Bdynamic myprog.f90
13.1.2. 64 位 Linux 注意事项
在 64 位 Linux 系统上,使用 -mcmodel=medium
选项的 64 位应用程序有时无法成功静态链接。因此,使用 -mcmodel=medium
选项构建可执行文件的用户可能需要使用共享库,进行动态链接。此外,使用 -fpic
选项构建的运行时库使用 32 位偏移量,因此它们有时需要驻留在 Linux 程序内存的共享区域中,靠近其他运行时 libs
。
注意
如果您的应用程序使用共享对象动态链接,则需要 NVIDIA HPC 编译器运行时的共享对象版本。
13.1.3. Linux 可再分发文件
安装使用 NVIDIA HPC 编译器构建的应用程序所需的运行时库共享对象版本的方法是手动分发。
当安装 NVIDIA HPC 编译器时,存在名称以 REDIST
开头的目录;这些目录包含再分发的共享对象库。获得许可的 NVIDIA HPC 编译器用户可以根据最终用户许可协议的条款再分发这些库。
13.1.4. Linux 可移植性的限制
您不能期望能够在任何给定的 Linux 机器上运行可执行文件。可移植性取决于您构建的系统,以及您的程序使用系统例程的程度,这些例程可能在不同的 Linux 版本之间发生更改。例如,某些 Linux 版本之间发生重大更改的区域是 libpthread.so
和 libnuma.so
。NVIDIA HPC 编译器将这些动态链接库用于选项 -acc
(OpenACC)、-mp
(OpenMP) 和 -Mconcur
(多核自动并行)。静态链接这些库可能不可行,或者可能导致执行失败。
通常,向前执行支持可移植性,这意味着在相同或更高版本的 Linux 上运行程序。但不适用于向后兼容性,即在早期版本上运行。例如,在 RHEL 7.2 下编译和链接程序的用户不应期望该程序在 RHEL 5.2 系统(早期 Linux 版本)上无故障运行。它可能会运行,但可能性较小。开发人员可以考虑在早期 Linux 版本上构建应用程序以获得更广泛的用途。在执行程序的平台上动态链接 Linux 和 gcc 系统例程也可以减少问题。
13.1.5. 可再分发 (REDIST) 文件的许可
REDIST 目录中的文件可以根据它们所包含产品的最终用户许可协议的条款进行再分发。
14. 跨语言调用
本节介绍使用 HPC 编译器的 C、C++ 和 Fortran 程序的跨语言调用约定。Fortran 2003 ISO_C_Binding 提供了一种支持与 C 互操作性的机制。这包括 iso_c_binding
内部模块、绑定标签和 BIND 属性。Fortran 2018 和 ISO_Fortran_binding.h
C 头文件提供了与 C 的其他互操作性。nvfortran 同时支持 iso_c_binding
和 ISO_Fortan_Binding.h
头文件。在缺少这些机制的情况下,以下各节介绍如何从 C 或 C++ 程序调用 Fortran 函数或子例程,以及如何从 Fortran 程序调用 C 或 C++ 函数。
本节提供使用以下与跨语言调用相关的选项的示例。
-c
-Mnomain
-Miface
-Mupcase
14.1. 调用约定概述
本节包含有关以下主题的信息
Fortran、C 和 C++ 中的函数和子例程
命名和大小写转换约定
兼容的数据类型
参数传递和特殊返回值
数组和索引
跨语言调用注意事项 至 示例 – C++ 调用 Fortran 各节介绍了如何使用 Linux 或 Win64 约定执行跨语言调用。
14.2. 跨语言调用注意事项
通常,当参数数据类型和函数返回值一致时,您可以从 Fortran 调用 C 或 C++ 函数,也可以从 C 或 C++ 调用 Fortran 函数。当参数的数据类型不一致时,您可能需要开发自定义机制来处理它们。例如,Fortran COMPLEX
类型在 C99 中有匹配的类型,但在 C89 中没有匹配的类型;但是,仍然可以提供跨语言调用,但对于这种情况没有通用的调用约定。
如果 C++ 函数包含带有构造函数和析构函数的对象,则除非从 C++ 程序(构造函数和析构函数在其中正确初始化)执行主程序中的初始化,否则无法从 C 或 Fortran 调用此类函数。
通常,您可以从 C++ 调用 C 或 Fortran 函数而不会出现问题,只要您使用 extern “C” 关键字在 C++ 程序中声明该函数即可。此声明可防止 C 函数名称的名称修饰。如果您想从 C 或 Fortran 调用 C++ 函数,您还必须使用 extern “C” 关键字来声明 C++ 函数。这可以防止 C++ 编译器修饰函数名称。
您可以使用 __cplusplus 宏来允许程序或头文件同时用于 C 和 C++。例如,头文件 stdio.h 中的以下定义允许此文件同时用于 C 和 C++。
#ifndef _STDIO_H #define _STDIO_H #ifdef __cplusplus extern "C" { #endif /* __cplusplus */ . . /* Functions and data types defined... */ . #ifdef __cplusplus } #endif /* __cplusplus */ #endif
C++ 成员函数不能声明为
extern
,因为它们的名称将始终被修饰。因此,C++ 成员函数不能从 C 或 Fortran 调用。
14.3. 函数和子例程
Fortran、C 和 C++ 以不同的方式定义函数和子例程。
对于 Fortran 程序调用 C 或 C++ 函数,请遵守以下返回值约定
当 C 或 C++ 函数返回值时,从 Fortran 中将其作为函数调用。
当 C 或 C++ 函数不返回值时,将其作为子例程调用。
对于 C/C++ 程序调用 Fortran 函数,该调用应返回相似的类型。表 28,Fortran 和 C/C++ 数据类型兼容性,列出了兼容的类型。如果调用是 Fortran 子例程、Fortran CHARACTER
函数或 Fortran COMPLEX
函数,则从 C/C++ 中将其作为返回 void 的函数调用。此约定的例外情况是当 Fortran 子例程具有备用返回时;从 C/C++ 中将此类子例程作为返回 int
的函数调用,其值是在备用 RETURN
语句中指定的整数表达式的值。
14.4. 大写和小写约定,下划线
默认情况下,在 Linux 和 Win64 系统上,所有 Fortran 符号名称都转换为小写。C 和 C++ 区分大小写,因此大写函数名称保持大写。当您使用跨语言调用时,您可以为 C/C++ 函数命名为小写名称,或者使用选项 -Mupcase
调用 Fortran 编译器命令,在这种情况下,它不会将符号名称转换为小写。
当在 Linux 和 Win64 系统上使用 HPC Fortran 编译器之一编译程序时,会在 Fortran 全局名称(函数、子例程和公共块的名称)后附加下划线。此机制区分 Fortran 命名空间和 C/C++ 命名空间。使用以下命名约定
如果您从 Fortran 调用 C/C++ 函数,则应通过附加下划线或在 Fortran 程序中使用
bind(c)
来重命名 C/C++ 函数。如果您从 C/C++ 调用 Fortran 函数,则应在调用程序中的 Fortran 函数名称后附加下划线。
14.5. 兼容的数据类型
表 28 显示了 Fortran 和 C/C++ 之间兼容的数据类型。表 29,COMPLEX 类型的 Fortran 和 C/C++ 表示形式 显示了 Fortran COMPLEX
类型如何在 C/C++ 中表示。
指示内联器内联库文件 file.ext
中的过程。如果未指定内联库,则从在提取预处理期间创建的临时库中提取过程。
如果您可以使您的函数/子例程参数以及返回值匹配类型,您应该能够使用跨语言调用。
Fortran 类型(小写) |
C/C++ 类型 |
大小(字节) |
---|---|---|
|
|
1 |
|
|
n |
|
|
4 |
|
|
4 |
|
|
8 |
|
|
8 |
|
|
4 |
|
|
1 |
|
|
2 |
|
|
4 |
|
|
8 |
|
|
4 |
|
|
1 |
|
|
2 |
|
|
4 |
|
|
8 |
Fortran 类型(小写) |
C/C++ 类型 |
大小(字节) |
---|---|---|
|
|
8 |
|
8 |
|
|
|
8 |
|
8 |
|
|
|
16 |
|
16 |
|
|
|
16 |
|
16 |
注意
对于 C/C++,complex
类型暗示 C99 或更高版本。
14.5.1. Fortran 命名公共块
命名的 Fortran 公共块可以在 C/C++ 中用结构体表示,其成员与公共块的成员相对应。C/C++ 中结构体的名称必须添加下划线。例如,这是一个 Fortran 公共块
INTEGER I
COMPLEX C
DOUBLE COMPLEX CD
DOUBLE PRECISION D
COMMON /COM/ i, c, cd, d
此 Fortran 公共块在 C 中用以下等效项表示
extern struct {
int i;
struct {float real, imag;} c;
struct {double real, imag;} cd;
double d;
} com_;
同一个 Fortran 公共块在 C++ 中用以下等效项表示
extern "C" struct {
int i;
struct {float real, imag;} c;
struct {double real, imag;} cd;
double d;
} com_;
指示内联器内联库文件 file.ext
中的过程。如果未指定内联库,则从在提取预处理期间创建的临时库中提取过程。
对于全局或外部数据共享,不需要 extern "C"
。
14.6. 参数传递和返回值
在 Fortran 中,参数通过引用传递,即传递参数的地址,而不是参数本身。在 C/C++ 中,参数按值传递,字符串和数组除外,它们通过引用传递。由于 C/C++ 中提供的灵活性,您可以解决这些差异。解决参数传递差异通常涉及在 C/C++ 调用 Fortran 时智能地使用 &
和 *
运算符进行参数传递,以及在 Fortran 调用 C/C++ 时智能地使用参数声明。
对于在 Fortran 中声明为 CHARACTER
类型的字符串,表示字符串长度的参数也传递给调用函数。
在 Linux 系统上,编译器将长度参数放在参数列表的末尾,在其他形式参数之后。
长度参数按值传递,而不是按引用传递。
14.6.1. 按值传递 (%VAL)
当从 Fortran 子程序向 C/C++ 函数传递参数时,可以使用 %VAL
函数按值传递。如果您用 %VAL()
括起 Fortran 参数,则该参数将按值传递。例如,以下调用按值传递整数 i
和逻辑 bvar
。
integer*1 i
logical*1 bvar
call cvalue (%VAL(i), %VAL(bvar))
14.6.2. 字符返回值
函数和子例程 介绍了 C/C++ 和 Fortran 跨语言调用的返回值的一般规则。有一个特殊的返回值需要考虑。当 Fortran 函数返回字符时,需要在 C/C++ 调用函数的参数列表的开头添加两个参数:
返回字符的地址
返回字符的长度
以下示例说明了调用者提供的额外参数 tmp
和 10
字符返回参数
! Fortran function returns a character
CHARACTER*(*) FUNCTION CHF(C1,I)
CHARACTER*(*) C1
INTEGER I
END
/* C declaration of Fortran function */
extern void chf_();
char tmp[10];
char c1[9];
int i;
chf_(tmp, 10, c1, &i, 9);
如果 Fortran 函数声明为返回常量长度的字符值,例如 CHARACTER*4 FUNCTION CHF()
,则表示长度的第二个额外参数仍然必须提供,但未使用。
注意
字符函数的值不会自动以 NULL 结尾。
14.6.3. 复数返回值
当 Fortran 函数返回复数值时,需要在 C/C++ 调用函数的参数列表的开头添加一个参数;此参数是复数返回值的地址。复数返回值 说明了调用者提供的额外参数 cplx
。
复数返回值
COMPLEX FUNCTION CF(C, I)
INTEGER I
. . .
END
extern void cf_();
typedef struct {float real, imag;} cplx;
cplx c1;
int i;
cf_(&c1, &i);
14.7. 数组索引
C/C++ 数组和 Fortran 数组使用不同的默认初始数组索引值。默认情况下,C/C++ 中的数组从 0 开始,而 Fortran 中的数组从 1 开始。如果您调整数组比较,使 Fortran 的第二个元素与 C/C++ 的第一个元素进行比较,并对其他元素进行类似调整,则您应该不会在使用此差异时遇到问题。如果这不能令人满意,您可以声明您的 Fortran 数组从零开始。
Fortran 和 C/C++ 数组之间的另一个区别是使用的存储方法。Fortran 使用列优先顺序,而 C/C++ 使用行优先顺序。对于一维数组,这不会造成问题。对于二维数组,如果行数和列数相等,则可以简单地反转行索引和列索引。对于单维数组和正方形二维数组以外的数组,不建议混合使用跨语言函数。
14.8. 示例
本节包含说明跨语言调用的示例。
14.8.1. 示例 – Fortran 调用 C
注意
调用 C 来自 Fortran 有其他解决方案,而不是本节中介绍的解决方案。例如,您可以使用 NVIDIA 支持的 iso_c_binding
内部模块。有关此模块的更多信息以及如何使用它的示例,请使用关键字 iso_c_binding 在 Web 上搜索。
C 函数 f2c_func_ 显示了一个由 Fortran 主程序 f2c_main.f 中显示的 Fortran 主程序调用的 C 函数。请注意,每个参数都定义为指针,因为 Fortran 按引用传递。另请注意,C 函数名称使用全小写和尾随“_”。
Fortran 主程序 f2c_main.f
logical*1 bool1
character letter1
integer*4 numint1, numint2
real numfloat1
double precision numdoub1
integer*2 numshor1
external f2c_func
call f2c_func(bool1, letter1, numint1, numint2, numfloat1, numdoub1, numshor1)
write( *, "(L2, A2, I5, I5, F6.1, F6.1, I5)")
+ bool1, letter1, numint1, numint2, numfloat1,numdoub1, numshor1
end
C 函数 f2c_func_
#define TRUE 0xff
#define FALSE 0
void f2c_func_( bool1, letter1, numint1, numint2, numfloat1,\
numdoub1, numshor1, len_letter1)
char *bool1, *letter1;
int *numint1, *numint2;
float *numfloat1;
double *numdoub1;
short *numshor1;
int len_letter1;
{
*bool1 = TRUE; *letter1 = 'v';
*numint1 = 11; *numint2 = -44;
*numfloat1 = 39.6 ;
*numdoub1 = 39.2;
*numshor1 = 981;
}
使用以下命令行编译并执行程序 f2c_main.f
,其中调用了 f2c_func\_
$ nvc -c f2c_func.c
$ nvfortran f2c_func.o f2c_main.f
执行 a.out
文件应产生以下输出
T v 11 -44 39.6 39.2 981
14.8.2. 示例 - C 调用 Fortran
注意
调用 Fortran 来自 C 有其他解决方案,而不是本节中介绍的解决方案。例如,您可以使用 NVIDIA 支持的 ISO_Fortran_binding.h
C 头文件。有关此头文件的更多信息以及如何使用它的示例,请使用关键字 ISO_Fortran_binding 在 Web 上搜索。
C 主程序 c2f_main.c 显示了一个 C 主程序,它调用了 Fortran 子例程 c2f_sub.f 中显示的 Fortran 子例程。
每个调用都使用 & 运算符按引用传递。
对 Fortran 子例程的调用使用全小写和尾随“_”。
C 主程序 c2f_main.c
void main () {
char bool1, letter1;
int numint1, numint2;
float numfloat1;
double numdoub1;
short numshor1;
extern void c2f_func_();
c2f_sub_(&bool1,&letter1,&numint1,&numint2,&numfloat1,&numdoub1,&numshor1, 1);
printf(" %s %c %d %d %3.1f %.0f %d\n",
bool1?"TRUE":"FALSE", letter1, numint1, numint2,
numfloat1, numdoub1, numshor1);
}
Fortran 子例程 c2f_sub.f
subroutine c2f_func ( bool1, letter1, numint1, numint2,
+ numfloat1, numdoub1, numshor1)
logical*1 bool1
character letter1
integer numint1, numint2
double precision numdoub1
real numfloat1
integer*2 numshor1
bool1 = .true.
letter1 = "v"
numint1 = 11
numint2 = -44
numdoub1 = 902
numfloat1 = 39.6
numshor1 = 299
return
end
要编译此 Fortran 子例程和 C 程序,请使用以下命令
$ nvc -c c2f_main.c
$ nvfortran -Mnomain c2f_main.o c2_sub.f
执行生成的 a.out
文件应产生以下输出
TRUE v 11 -44 39.6 902 299
14.8.3. 示例 – C++ 调用 C
C++ 主程序 cp2c_main.C 调用 C 函数 显示了一个 C++ 主程序,它调用了 简单 C 函数 c2cp_func.c 中显示的 C 函数。
C++ 主程序 cp2c_main.C 调用 C 函数
extern "C" void cp2c_func(int n, int m, int *p);
#include <iostream>
main()
{
int a,b,c;
a=8;
b=2;
c=0;
cout << "main: a = "<<a<<" b = "<<b<<"ptr c = "<<hex<<&c<< endl;
cp2c_func(a,b,&c);
cout << "main: res = "<<c<<endl;
}
简单 C 函数 c2cp_func.c
void cp2c_func(num1, num2, res)
int num1, num2, *res;
{
printf("func: a = %d b = %d ptr c = %x\n",num1,num2,res);
*res=num1/num2;
printf("func: res = %d\n",*res);
}
要编译此 C 函数和 C++ 主程序,请使用以下命令
$ nvc -c cp2c_func.c
$ nvc++ cp2c_main.C cp2c_func.o
执行生成的 a.out 文件应产生以下输出
main: a = 8 b = 2 ptr c = 0xbffffb94
func: a = 8 b = 2 ptr c = bffffb94
func: res = 4
main: res = 4
14.8.4. 示例 – C 调用 C ++
C 主程序 c2cp_main.c 调用 C++ 函数 显示了一个 C 主程序,它调用了 带有 Extern C 的简单 C++ 函数 c2cp_func.C 中显示的 C++ 函数。
C 主程序 c2cp_main.c 调用 C++ 函数
extern void c2cp_func(int a, int b, int *c);
#include <stdio.h>
main() {
int a,b,c;
a=8; b=2;
printf("main: a = %d b = %d ptr c = %x\n",a,b,&c);
c2cp_func(a,b,&c);
printf("main: res = %d\n",c);
}
带有 Extern C 的简单 C++ 函数 c2cp_func.C
#include <iostream>
extern "C" void c2cp_func(int num1,int num2,int *res)
{
cout << "func: a = "<<num1<<" b = "<<num2<<"ptr c ="<<res<<endl;
*res=num1/num2;
cout << "func: res = "<<res<<endl;
}
要编译此 C 函数和 C++ 主程序,请使用以下命令
$ nvc -c c2cp_main.c
$ nvc++ c2cp_main.o c2cp_func.C
执行生成的 a.out 文件应产生以下输出
main: a = 8 b = 2 ptr c = 0xbffffb94
func: a = 8 b = 2 ptr c = bffffb94
func: res = 4
main: res = 4
注意
您不能将声明的 extern “C” 形式用于对象的成员函数。
14.8.5. 示例 – Fortran 调用 C++
Fortran 主程序 f2cp_main.f 调用 C++ 函数 中显示的 Fortran 主程序调用了 C++ 函数 f2cp_func.C 中显示的 C++ 函数。
注意
每个参数都在 C++ 函数中定义为指针,因为 Fortran 按引用传递。
C++ 函数名使用全小写字母并以“_”结尾
Fortran 主程序 f2cp_main.f 调用 C++ 函数
logical*1 bool1
character letter1
integer*4 numint1, numint2
real numfloat1
double precision numdoub1
integer*2 numshor1
external f2cpfunc
call f2cp_func (bool1, letter1, numint1,
+ numint2, numfloat1, numdoub1, numshor1)
write( *, "(L2, A2, I5, I5, F6.1, F6.1, I5)")
+ bool1, letter1, numint1, numint2, numfloat1,
+ numdoub1, numshor1
end
C++ 函数 f2cp_func.C
#define TRUE 0xff
#define FALSE 0
extern "C"
{
extern void f2cp_func_ (
char *bool1, *letter1,
int *numint1, *numint2,
float *numfloat1,
double *numdoub1,
short *numshort1,
int len_letter1)
{
*bool1 = TRUE; *letter1 = 'v';
*numint1 = 11; *numint2 = -44;
*numfloat1 = 39.6; *numdoub1 = 39.2; *numshort1 = 981;
}
}
假设 Fortran 程序在文件 fmain.f 中,C++ 函数在文件 cpfunc.C 中,使用以下命令行创建可执行文件
$ nvc++ -c f2cp_func.C
$ nvfortran f2cp_func.o f2cp_main.f -c++libs
执行 a.out 文件应产生以下输出
T v 11 -44 39.6 39.2 981
14.8.6. 示例 – C++ 调用 Fortran
Fortran 子例程 cp2f_func.f 展示了一个由 C++ 主程序调用的 Fortran 子例程,该 C++ 主程序在 C++ 主程序 cp2f_main.C 中展示。请注意,每次调用都使用 &
运算符按引用传递。另请注意,对 Fortran 子例程的调用使用全小写字母并以“_
”结尾
C++ 主程序 cp2f_main.C
#include <iostream>
extern "C" { extern void cp2f_func_(char *,char *,int *,int *,
float *,double *,short *); }
main ()
{
char bool1, letter1;
int numint1, numint2;
float numfloat1;
double numdoub1;
short numshor1;
cp2f_func(&bool1,&letter1,&numint1,&numint2,&numfloat1, &numdoub1,&numshor1);
cout << " bool1 = ";
bool1?cout << "TRUE ":cout << "FALSE "; cout <<endl;
cout << " letter1 = " << letter1 <<endl;
cout << " numint1 = " << numint1 <<endl;
cout << " numint2 = " << numint2 <<endl;
cout << " numfloat1 = " << numfloat1 <<endl;
cout << " numdoub1 = " << numdoub1 <<endl;
cout << " numshor1 = " << numshor1 <<endl;
}
Fortran 子例程 cp2f_func.f
subroutine cp2f_func ( bool1, letter1, numint1,
+ numint2, numfloat1, numdoub1, numshor1)
logical*1 bool1
character letter1
integer numint1, numint2
double precision numdoub1
real numfloat1
integer*2 numshor1
bool1 = .true. ; letter1 = "v"
numint1 = 11 ; numint2 = -44
numdoub1 = 902 ; numfloat1 = 39.6 ; numshor1 = 299
return
end
要编译此 Fortran 子例程和 C++ 程序,请使用以下命令行
$ nvfortran -c cp2f_func.f
$ nvc++ cp2f_func.o cp2f_main.C -fortranlibs
执行此 C++ 主程序应产生以下输出
bool1 = TRUE
letter1 = v
numint1 = 11
numint2 = -44
numfloat1 = 39.6
numdoub1 = 902
numshor1 = 299
注意
当将 nvfortran 编译的程序单元链接到 C++ 或 C 主程序中时,您必须显式链接 NVFORTRAN 运行时支持库。
15. 64 位环境的编程注意事项
NVIDIA 为运行在 x86-64 (Linux_x86_64) 和 Arm 服务器 (Linux_aarch64) 架构上的 64 位 Linux 操作系统提供 64 位编译器。您可以使用这些编译器创建使用 64 位内存地址的程序。64 位 Linux 系统上的 GNU 工具链实现了一个选项来控制 32 位与 64 位代码生成,如 Linux 中的大型静态数据 中所述。本节描述如何具体使用 NVIDIA 编译器来利用 64 位内存寻址。
注意
NVIDIA HPC 编译器本身是 64 位应用程序,只能在运行 64 位操作系统的 64 位 CPU 上运行。
本节描述如何使用以下与 64 位编程相关的选项。
-fPIC
-mcmodel=medium
-Mlarge_arrays
-i8
-Mlargeaddressaware
15.1. 64 位环境中的数据类型
某些数据类型的大小在 64 位环境中可能有所不同。本节描述主要区别。
15.1.1. C++ 和 C 数据类型
在 64 位 Linux 操作系统上,int 的大小为 4 字节,long 为 8 字节,long long 为 8 字节,指针为 8 字节。
15.1.2. Fortran 数据类型
在 Fortran 中,INTEGER 类型的默认大小为 4 字节。-i8
编译器选项可用于使程序中所有 INTEGER 数据的默认大小为 8 字节。
当使用 -Mlarge_arrays
选项(在 64 位数组索引 中描述)时,任何用于索引数组的 4 字节 INTEGER 变量都将由编译器静默提升为 8 字节。这种提升可能会导致意外的后果,因此当使用 -Mlarge_arrays
选项时,建议将 8 字节 INTEGER 变量用于数组索引。
15.2. Linux 中的大型静态数据
64 位 Linux 操作系统支持两种不同的内存模型。NVIDIA HPC 编译器在 Linux_x86_64 和 Linux_aarch64 目标上使用的默认模型是小型内存模型,可以使用 -mcmodel=small 指定。这是 32 位模型,它将代码加上静态分配的数据(包括系统库和用户库)的大小限制为 2GB。中型内存模型(由 -mcmodel=medium 指定)允许组合的代码和静态数据区域(.text 和 .bss 段)大于 2GB。为了生效,-mcmodel=medium
选项必须在编译命令和链接命令中都使用。
使用 -mcmodel=medium
会产生一些影响。生成的代码需要增加寻址开销以支持大的数据范围。这可能会影响性能,但编译器会努力通过仔细的指令选择和优化来最大限度地减少增加的开销。
Linux_aarch64 不支持 -mcmodel=medium。如果在命令行上指定了中型模型,则编译器驱动程序将自动选择大型模型。
15.3. 大型动态分配数据
由 NVIDIA HPC 编译器编译的程序中的动态分配数据对象可以大于 2GB。启用此功能不需要特殊的编译器选项。分配的大小仅受系统限制。但是,要正确访问具有超过 2G 元素的动态分配数组,您应该使用 -Mlarge_arrays
选项,这将在以下部分中描述。
15.4. 64 位数组索引
NVIDIA Fortran 编译器提供了一个选项 -Mlarge_arrays
,它启用数组的 64 位索引。这意味着,在必要时,将使用 64 位 INTEGER 常量和变量来索引数组。
注意
在存在 -Mlarge_arrays
的情况下,编译器可能会静默地将 32 位整数提升为 64 位,这可能会产生意想不到的副作用。
在 64 位 Linux 上,-Mlarge_arrays
选项还启用大于 2 GB 的单个静态数据对象。在存在 -mcmodel=medium
的情况下,此选项是默认选项。
15.5. 64 位编程的编译器选项
适用于寻求增加其应用程序数据范围的 64 位程序员的常用开关在下表中。
选项 |
目的 |
注意事项 |
---|---|---|
|
允许数据声明大于 2GB。 |
Linux_aarch64 不支持 -mcmodel=medium。如果在命令行上指定了中型模型,则编译器驱动程序将自动选择大型模型。 |
|
使用 64 位整数算术执行所有数组位置到地址的计算。 |
执行速度稍慢。与 |
|
位置无关代码。共享库的必要条件。 |
动态链接限制为 32 位偏移量。外部符号引用应指向其他共享库例程,而不是调用它们的程序。 |
|
所有未显式声明为 INTEGER*4 的 INTEGER 函数、数据和常量都被假定为 INTEGER*8。 |
用户应注意将 INTEGER 函数显式声明为 INTEGER*4。 |
下表总结了在指定条件下这些编程模型的限制。您使用的编译器选项因处理器而异。
条件 |
地址运算 |
最大大小 GB |
|||
---|---|---|---|---|---|
A |
I |
AS |
DS |
TS |
|
受选项 |
64 |
32 |
2 |
2 |
2 |
-fpic 与 |
64 |
32 |
2 |
2 |
2 |
启用对 64 位数据寻址的完全支持 |
64 |
64 |
>2 |
>2 |
>2 |
|
地址类型 – 用于地址计算的数据的大小(以位为单位),64 位。 |
|
索引算术 - 用于索引数组和其他聚合数据结构的数据的位大小。如果为 32 位,则任何单个数据对象的总范围都限制为 2GB。 |
|
最大数组大小 - 任何单个数据对象的最大大小(以 GB 为单位)。 |
|
最大数据大小 - .bss 中所有数据对象的最大组合大小(以 GB 为单位) |
|
最大总大小 - 运行程序中所有可执行代码和数据对象的最大聚合大小(以 GB 为单位)。 |
15.6. 大型数组编程的实际限制
当数据大小显著增大时,64 位 Linux 环境的 64 位寻址能力可能会导致意外问题。下表描述了大型数组编程实际限制的最常见情况。
数组初始化 |
使用数据语句初始化大型数组可能会导致非常大的汇编文件和目标文件,其中初始化的数组中的每个元素都需要一行汇编器源代码。编译和链接也可能非常耗时。为避免此问题,请考虑在运行时循环中初始化大型数组,而不是在数据语句中初始化。 |
堆栈空间 |
对于基于堆栈的数据,堆栈空间可能是一个问题。在 Linux 上,堆栈大小在您的 shell 环境中增加。如果将堆栈大小设置为 unlimited 仍然不够大,请尝试显式设置大小 limit stacksize new_size ! in csh
ulimit -s new_size ! in bash
|
页面交换 |
如果您的可执行文件远大于物理内存大小,则页面交换可能会导致其运行速度显著降低;甚至可能失败。这不是编译器问题。尝试较小的数据集以确定问题是否是由于页面抖动引起的。 |
配置空间 |
确保您的 Linux 系统配置了足够大的交换空间来支持您的应用程序中使用的数据集。如果您的内存 + 交换空间不够大,您的应用程序很可能会在运行时遇到段错误。 |
对象文件格式中对大型地址偏移量的支持 |
未动态分配的数组受到编译器在生成代码时如何表达它们之间“距离”的限制。对象文件中的一个字段存储此“距离”值,在 Linux 上使用 注意 如果没有对象文件格式中的 64 位偏移量支持,则大型数组无法静态声明,也无法在堆栈上本地声明。 |
15.7. C 中的中型内存模型和大型数组
考虑以下示例,其中数组的聚合大小超过 2GB。
C 中的中型内存模型和大型数组
% cat bigadd.c
#include <stdio.h>
#define SIZE 600000000 /* > 2GB/4 */
static float a[SIZE], b[SIZE];
int
main()
{
long long i, n, m;
float c[SIZE]; /* goes on stack */
n = SIZE;
m = 0;
for (i = 0; i < n; i += 10000) {
a[i] = i + 1;
b[i] = 2.0 * (i + 1);
c[i] = a[i] + b[i];
m = i;
}
printf("a[0]=%g b[0]=%g c[0]=%g\n", a[0], b[0], c[0]);
printf("m=%lld a[%lld]=%g b[%lld]=%gc[%lld]=%g\n",m,m,a[m],m,b[m],m,c[m]);
return 0;
}
% nvc -mcmodel=medium -o bigadd bigadd.c
当 SIZE 大于 2G/4 且数组类型为 float(每个元素 4 字节)时,每个数组的大小都大于 2GB。使用 nvc 和 -mcmodel=medium 开关,静态数据对象现在可以 > 2GB。如果您在您的环境中使用这些设置执行,您可能会看到以下内容
% bigadd
Segmentation fault
执行失败,因为堆栈大小不够大。您很可能可以通过使用 limit stacksize
命令在您的环境中重置堆栈大小来纠正此错误
% limit stacksize 3000M
注意
limit stacksize unlimited
命令可能无法提供像我们在 此示例 中使用的那么大的堆栈。
% bigadd
a[0]=1 b[0]=2 c[0]=3
n=599990000 a[599990000]=5.9999e+08 b[599990000]=1.19998e+09
c[599990000]=1.79997e+09
15.8. Fortran 中的中型内存模型和大型数组
以下示例适用于 NVFORTRAN 编译器。当使用 -mcmodel=medium
选项时,它使用 64 位地址和索引算术。
考虑以下示例
Fortran 中的中型内存模型和大型数组
% cat mat.f
program mat
integer i, j, k, size, l, m, n
parameter (size=16000) ! >2GB
parameter (m=size,n=size)
real*8 a(m,n),b(m,n),c(m,n),d
do i = 1, m
do j = 1, n
a(i,j)=10000.0D0*dble(i)+dble(j)
b(i,j)=20000.0D0*dble(i)+dble(j)
enddo
enddo
!$omp parallel
!$omp do
do i = 1, m
do j = 1, n
c(i,j) = a(i,j) + b(i,j)
enddo
enddo
!$omp do
do i=1,m
do j = 1, n
d = 30000.0D0*dble(i)+dble(j)+dble(j)
if (d .ne. c(i,j)) then
print *,"err i=",i,"j=",j
print *,"c(i,j)=",c(i,j)
print *,"d=",d
stop
endif
enddo
enddo
!$omp end parallel
print *, "M =",M,", N =",N
print *, "c(M,N) = ", c(m,n)
end
使用 NVFORTRAN 编译器和 -mcmodel=medium
编译时
% nvfortran -Mfree -mp -o mat mat.f -i8 -mcmodel=medium
% setenv OMP_NUM_THREADS 2
% mat
M = 16000 , N = 16000
c(M,N) = 480032000.0000000
15.9. Fortran 中的大型数组和小型内存模型
以下示例使用大型动态分配数组。代码分为主程序和子例程,因此您可以将子例程放入共享库中。动态分配大型数组可以节省可执行文件的大小空间,并节省初始化数据的时间。
Fortran 中的大型数组和小型内存模型
% cat mat_allo.f90
program mat_allo
integer i, j
integer size, m, n
parameter (size=16000)
parameter (m=size,n=size)
double precision, allocatable::a(:,:),b(:,:),c(:,:)
allocate(a(m,n), b(m,n), c(m,n))
do i = 100, m, 1
do j = 100, n, 1
a(i,j) = 10000.0D0 * dble(i) + dble(j)
b(i,j) = 20000.0D0 * dble(i) + dble(j)
enddo
enddo
call mat_add(a,b,c,m,n)
print *, "M =",m,",N =",n
print *, "c(M,N) = ", c(m,n)
end
subroutine mat_add(a,b,c,m,n)
integer m, n, i, j
double precision a(m,n),b(m,n),c(m,n)
do i = 1, m
do j = 1, n
c(i,j) = a(i,j) + b(i,j)
enddo
enddo
return
end
% nvfortran -o mat_allo mat_allo.f90 -i8 -Mlarge_arrays -mp -fast
16. C++ 和 C 内联汇编和内联函数
本节中的示例使用 x86-64 汇编指令展示。内联汇编也支持 Arm 服务器平台,但本节未详细记录。
16.1. 内联汇编
内联汇编允许您在 “C” 函数内部指定机器指令。内联汇编指令的格式如下
{ asm | __asm__ } ("string");
asm 语句以 asm 或 __asm__ 关键字开头。__asm__ 关键字通常用于可能包含在 ISO “C” 程序中的头文件中。
string 是一个或多个机器特定的指令,用分号 (;) 或换行符 (\n) 分隔。这些指令直接插入到编译器为封闭函数生成的汇编语言输出中。
一些简单的 asm 语句是
asm ("cli");
asm ("sti");
这些 asm 语句分别禁用和启用系统中断。
在以下示例中,eax 寄存器设置为零。
asm( "pushl %eax\n\t" "movl $0, %eax\n\t" "popl %eax");
请注意,eax 被压入堆栈,以使其不被破坏。当语句完成 eax 后,它会通过 popl 指令恢复。
通常,程序使用包含 asm 语句的宏。以下两个示例使用本节前面创建的中断构造
#define disableInt __asm__ ("cli");
#define enableInt __asm__ ("sti");
16.2. 扩展内联汇编
内联汇编 解释了如何使用内联汇编在 “C” 函数内部指定机器特定指令。这种方法非常适用于简单的机器操作,例如禁用和启用系统中断。但是,内联汇编有三个明显的局限性
程序员必须选择内联汇编所需的寄存器。
为了防止寄存器被破坏,内联汇编必须包含 push 和 pop 代码,用于内联汇编修改的寄存器。
在内联汇编语句中,没有简单的方法访问堆栈变量。
扩展内联汇编 的创建是为了解决这些局限性。扩展内联汇编(也称为 扩展 asm)的格式如下
{ asm | __asm__ } [ volatile | __volatile__ ]
("string" [: [output operands]] [: [input operands]] [: [clobberlist]]);
扩展 asm 语句以 asm 或 __asm__ 关键字开头。通常,__asm__ 关键字用于可能被 ISO “C” 程序包含的头文件中。
可选的 volatile 或 __volatile__ 关键字可以出现在 asm 关键字之后。此关键字指示编译器不要删除、显著移动或与任何其他 asm 语句组合。与 __asm__ 类似,__volatile__ 关键字通常与可能被 ISO “C” 程序包含的头文件一起使用。
“string” 是一个或多个机器特定的指令,用分号 (;) 或换行符 (\n) 分隔。字符串还可以包含在 [输出操作数]、[输入操作数] 和 [破坏列表] 中指定的操作数。这些指令直接插入到编译器为封闭函数生成的汇编语言输出中。
[输出操作数]、[输入操作数] 和 [破坏列表] 项各自描述了指令对编译器的影响。例如
asm( "movl %1, %%eax\n" "movl %%eax, %0":"=r" (x) : "r" (y) : "%eax" );
其中
“=r” (x) 是一个输出操作数。
“r” (y) 是一个输入操作数。
“%eax” 是由一个寄存器 “%eax” 组成的破坏列表。
输出和输入操作数的表示法是一个用引号括起来的约束字符串,后跟一个表达式,并用括号括起来。约束字符串描述了输入和输出操作数如何在 asm “string” 中使用。例如,“r” 告诉编译器操作数是一个寄存器。“=” 告诉编译器操作数是只写的,这意味着在 asm 语句结束时,值存储在输出操作数的表达式中。
每个操作数在 asm “string” 中都通过百分号 “%” 及其编号引用。第一个操作数的编号为 0,第二个操作数的编号为 1,第三个操作数的编号为 2,依此类推。在前面的示例中,“%0” 引用输出操作数,“%1” 引用输入操作数。asm “string” 还包含 “%%eax”,它引用机器寄存器 “%eax”。像 “%eax” 这样的硬编码寄存器应在破坏列表中指定,以防止与编译器汇编语言输出中的其他指令冲突。[输出操作数]、[输入操作数] 和 [破坏列表] 项在以下部分中更详细地描述。
16.2.1. 输出操作数
[输出操作数] 是一个可选的输出约束和表达式对列表,用于指定 asm 语句的结果。输出约束是一个字符串,用于指定如何将结果传递给表达式。例如,“=r” (x) 表示输出操作数是一个只写寄存器,它在 asm 语句结束时将其值存储在 “C” 变量 x 中。一个例子如下
int x;
void example()
{
asm( "movl $0, %0" : "=r" (x) );
}
前面的示例将 0 赋值给 “C” 变量 x。对于此示例中的函数,编译器生成以下汇编代码。如果您想生成汇编列表,请使用 nvc -S 编译器选项编译该示例
example:
..Dcfb0:
pushq %rbp
..Dcfi0:
movq %rsp, %rbp
..Dcfi1:
..EN1:
## lineno: 8
movl $0, %eax
movl %eax, x(%rip)
## lineno: 0
popq %rbp
ret
在显示的生成汇编代码中,请注意编译器为第 5 行的 asm 语句生成了两个语句。编译器从 asm “string” 生成了 “movl $0, %eax”。另请注意,%eax 出现在 “%0” 的位置,因为编译器将 %eax 寄存器分配给变量 x。由于项 0 是一个输出操作数,因此结果必须存储在其表达式 (x) 中。
除了只写输出操作数外,还有读/写输出操作数,用 “+” 而不是 “=” 表示。例如,“+r” (x) 告诉编译器在 asm 语句的开头用变量 x 初始化输出操作数。
为了说明这一点,以下示例将变量 x 递增 1
int x=1;
void example2()
{
asm( "addl $1, %0" : "+r" (x) );
}
为了执行递增,必须使用变量 x 初始化输出操作数。读/写 约束修饰符 (“+”) 指示编译器使用其表达式初始化输出操作数。编译器为此 example2() 函数生成以下汇编代码
example2:
..Dcfb0:
pushq %rbp
..Dcfi0:
movq %rsp, %rbp
..Dcfi1:
..EN1:
## lineno: 5
movl x(%rip), %eax
addl $1, %eax
movl %eax, x(%rip)
## lineno: 0
popq %rbp
ret
从 example2() 代码中,在汇编代码中生成了两个无关的移动:一个 movl 用于初始化输出寄存器,第二个 movl 用于将其写入变量 x。要消除这些移动,请使用内存约束类型而不是寄存器约束类型,如下例所示
int x=1;
void example2()
{
asm( "addl $1, %0" : "+m" (x) );
}
编译器生成对内存约束位置的内存引用。这消除了两个无关的移动。由于汇编代码使用对变量 x 的内存引用,因此它不必在 asm 语句之前将 x 移动到寄存器中;也不需要在 asm 语句之后存储结果。其他约束类型可在 其他约束 中找到。
example2:
..Dcfb0:
pushq %rbp
..Dcfi0:
movq %rsp, %rbp
..Dcfi1:
..EN1:
## lineno: 5
addl $1, x(%rip)
## lineno: 0
popq %rbp
ret
到目前为止的示例仅使用了一个输出操作数。由于扩展 asm 接受输出操作数列表,因此 asm 语句可以有多个结果,如下例所示
void example4()
{
int x=1; int y=2;
asm( "addl $1, %1\n" "addl %1, %0": "+r" (x), "+m" (y) );
}
此示例将变量 y 递增 1,然后将其添加到变量 x。多个输出操作数用逗号分隔。第一个输出操作数是 asm “string” 中的项 0 (“%0”),第二个是项 1 (“%1”)。x 和 y 的结果值分别为 4 和 3。
16.2.2. 输入操作数
[输入操作数] 是一个可选的输入约束和表达式对列表,用于指定 asm 语句所需的 “C” 值。输入约束指定如何将数据传递到 asm 语句。例如,“r” (x) 表示输入操作数是一个寄存器,其中包含存储在 “C” 变量 x 中的值的副本。另一个示例是 “m” (x),它表示输入项是与变量 x 关联的内存位置。其他约束类型在 其他约束 中讨论。一个例子如下
void example5()
{
int x=1;
int y=2;
int z=3;
asm( "addl %2, %1\n" "addl %2, %0" : "+r" (x), "+m" (y) : "r" (z) );
}
前面的示例将变量 z(项 2)添加到变量 x 和变量 y。x 和 y 的结果值分别为 4 和 5。
此处值得提及的另一种输入约束类型是匹配约束。匹配约束用于指定一个操作数,该操作数同时充当输入和输出角色。一个例子如下
int x=1;
void example6()
{
asm( "addl $1, %1"
: "=r" (x)
: "0" (x) );
}
前面的示例等效于 输出操作数 中显示的 example2() 函数。约束/表达式对 “0” (x) 告诉编译器在 asm 语句的开头用变量 x 初始化输出项 0。x 的结果值为 2。另请注意,asm “string” 中的 “%1” 在这种情况下与 “%0” 的含义相同。那是因为只有一个操作数同时具有输入和输出角色。
匹配约束与 输出操作数 中提及的读/写输出操作数非常相似。但是,读/写输出操作数和匹配约束之间存在一个关键区别。匹配约束可以具有与其输出表达式不同的输入表达式。
以下示例对输入和输出角色使用不同的值
int x;
int y=2;
void example7()
{
asm( "addl $1, %1"
: "=r" (x)
: "0" (y) );
}
编译器为 example7() 生成以下汇编代码
example7:
..Dcfb0:
pushq %rbp
..Dcfi0:
movq %rsp, %rbp
..Dcfi1:
..EN1:
## lineno: 8
movl y(%rip), %eax
addl $1, %eax
movl %eax, x(%rip)
## lineno: 0
popq %rbp
ret
变量 x 用存储在 y 中的值(即 2)初始化。添加 1 后,变量 x 的结果值为 3。
由于匹配约束为输出操作数执行输入角色,因此输出操作数具有读/写(”+”)修饰符是没有意义的。实际上,编译器不允许将匹配约束与读/写输出操作数一起使用。输出操作数必须具有只写(”=”)修饰符。
16.2.3. 破坏列表
[破坏列表] 是一个可选的字符串列表,其中包含 asm “string” 中使用的机器寄存器。本质上,这些字符串告诉编译器哪些寄存器可能被 asm 语句破坏。通过将寄存器放在此列表中,程序员不必像传统内联汇编(在 内联汇编 中描述)中要求的那样显式地保存和恢复它们。编译器会处理此列表中寄存器所需的任何保存和恢复操作。
[破坏列表] 中的每个机器寄存器都是一个用逗号分隔的字符串。寄存器名称中的前导 ‘%’ 是可选的。例如,“%eax” 等效于 “eax”。在 asm “string” 中指定寄存器时,您必须在名称前面包含两个前导 ‘%’ 字符(例如,“%%eax”)。否则,编译器将表现得好像指定了错误的输入/输出操作数并生成错误消息。一个例子如下
void example8()
{
int x;
int y=2;
asm( "movl %1, %%eax\n"
"movl %1, %%edx\n"
"addl %%edx, %%eax\n"
"addl %%eax, %0"
: "=r" (x)
: "0" (y)
: "eax", "edx" );
}
此代码使用两个硬编码寄存器 eax 和 edx。它执行等效于 3*y 的操作并将其赋值给 x,产生结果 6。
除了机器寄存器外,破坏列表还可以包含以下特殊标志
- “cc”
asm 语句可能会更改控制代码寄存器。
- “memory”
asm 语句可能会以不可预测的方式修改内存。
当存在 “memory” 标志时,编译器不会跨 asm 语句将内存值缓存在寄存器中,也不会优化对该内存的存储或加载。例如
asm("call MyFunc":::"memory");
此 asm 语句包含 “memory” 标志,因为它包含一个调用。否则,如果没有 “memory” 标志,被调用者可能会破坏调用者正在使用的寄存器。
以下函数使用扩展 asm 和 “cc” 标志来计算小于或等于输入参数 n 的 2 的幂。
#pragma noinline
int asmDivideConquer(int n)
{
int ax = 0;
int bx = 1;
asm (
"LogLoop:n"
"cmp %2, %1n"
"jnle Donen"
"inc %0n"
"add %1,%1n"
"jmp LogLoopn"
"Done:n"
"dec %0n"
:"+r" (ax), "+r" (bx) : "r" (n) : "cc");
return ax;
}
使用 ‘cc’ 标志是因为 asm 语句包含一些可能更改控制代码寄存器的控制流。#pragma noinline 语句阻止编译器内联 asmDivideConquer() 函数。如果编译器内联 asmDivideConquer(),则它可能会非法复制生成汇编代码中的标签 LogLoop 和 Done。
16.2.4. 其他约束
操作数约束可以分为四个主要类别
简单约束
机器约束
多重备选约束
约束修饰符
16.2.5. 简单约束
最简单的约束类型是由字母或字符组成的字符串,称为简单约束,例如 输出操作数 中介绍的 “r” 和 “m” 约束。表 33 描述了这些约束。
约束 |
描述 |
---|---|
空格 |
空格字符被忽略。 |
E |
立即浮点操作数。 |
F |
与 “E” 相同。 |
g |
允许任何通用寄存器、内存或立即整数操作数。 |
i |
立即整数操作数。 |
m |
内存操作数。允许机器支持的任何地址。 |
n |
与 “i” 相同。 |
o |
与 “m” 相同。 |
p |
作为有效内存地址的操作数。与约束关联的表达式应评估为地址(例如,“p” (&x) )。 |
r |
通用寄存器操作数。 |
X |
与 “g” 相同。 |
0,1,2,..9 |
匹配约束。有关描述,请参见 输出操作数。 |
以下示例使用通用约束 “g”,它允许编译器为操作数选择合适的约束类型;编译器从通用寄存器、内存或立即操作数中选择。此代码允许编译器为 “y” 选择约束类型。
void example9()
{
int x, y=2;
asm( "movl %1, %0\n" : "=r"
(x) : "g" (y) );
}
此技术可以提高代码效率。例如,在编译 example9() 时,编译器将 y 的加载和存储替换为常量 2。然后,编译器可以在示例中为 y 操作数生成立即数 2。nvc 为我们的示例生成的汇编代码如下
example9:
..Dcfb0:
pushq %rbp
..Dcfi0:
movq %rsp, %rbp
..Dcfi1:
..EN1:
## lineno: 3
movl $2, %eax
## lineno: 6
popq %rbp
ret
在此示例中,请注意 “y” 操作数使用了 $2。
当然,如果 y 始终为 2,则可以使用立即值而不是带有 “i” 约束的变量,如下所示
void example10()
{
int x;
asm( "movl %1, %0\n"
: "=r" (x)
: "i" (2) );
}
使用 nvc 编译 example10() 会产生类似于为 example9() 生成的汇编代码。
16.2.6. 机器约束
约束的另一个类别是机器约束。x86_64 架构有几个寄存器类。要选择特定的寄存器类,您可以使用 表 34 中描述的 x86_64 机器约束。
约束 |
描述 |
---|---|
a |
a 寄存器(例如,%al、%ax、%eax、%rax) |
A |
指定 a 或 d 寄存器。d 寄存器保存最高有效位,a 寄存器保存最低有效位。 |
b |
b 寄存器(例如,%bl、%bx、%ebx、%rbx) |
c |
c 寄存器(例如,%cl、%cx、%ecx、%rcx) |
C |
不支持。 |
d |
d 寄存器 (例如,%dl, %dx, %edx, %rdx) |
D |
di 寄存器 (例如,%dil, %di, %edi, %rdi) |
e |
范围在 0xffffffff 到 0x7fffffff 的常量 |
f |
不支持。 |
G |
范围在 0.0 到 1.0 的浮点常量。 |
I |
范围在 0 到 31 的常量 (例如,用于 32 位移位)。 |
J |
范围在 0 到 63 的常量 (例如,用于 64 位移位) |
K |
范围在 0 到 127 的常量。 |
L |
范围在 0 到 65535 的常量。 |
M |
范围在 0 到 3 常量的常量 (例如,lea 指令的移位)。 |
N |
范围在 0 到 255 的常量 (例如,用于 out 指令)。 |
q |
与 “r” 简单约束相同。 |
Q |
与 “r” 简单约束相同。 |
R |
与 “r” 简单约束相同。 |
S |
si 寄存器 (例如,%sil, %si, %edi, %rsi) |
t |
不支持。 |
u |
不支持。 |
x |
XMM SSE 寄存器 |
y |
不支持。 |
Z |
范围在 0 到 0x7fffffff 的常量。 |
以下示例使用 “x” 或 XMM 寄存器约束从 b 中减去 c,并将结果存储在 a 中。
double example11()
{
double a;
double b = 400.99;
double c = 300.98;
asm ( "subpd %2, %0;"
:"=x" (a)
: "0" (b), "x" (c)
);
return a;
}
为此示例生成的汇编代码如下
example11:
..Dcfb0:
pushq %rbp
..Dcfi0:
movq %rsp, %rbp
..Dcfi1:
..EN1:
## lineno: 4
movsd .C00128(%rip), %xmm1
movsd .C00130(%rip), %xmm2
movapd %xmm1, %xmm0
subpd %xmm2, %xmm0;
## lineno: 10
## lineno: 11
popq %rbp
ret
如果指定的寄存器不可用,则 nvc 和 nvc++ 编译器会发出错误消息。
16.2.7. 多种备选约束
有时,单条指令可以接受多种操作数类型。例如,x86-64 允许寄存器到内存和内存到寄存器的操作。为了在内联汇编中允许这种灵活性,请使用多种备选约束。备选是每个操作数的一系列约束。
要指定多种备选方案,请用逗号分隔每个备选方案。
约束 |
描述 |
---|---|
, |
分隔特定操作数的每个备选方案。 |
? |
忽略 |
! |
忽略 |
以下示例对加法运算使用多种备选方案。
void example13()
{
int x=1;
int y=1;
asm( "addl %1, %0\n"
: "+ab,cd" (x)
: "db,cam" (y) );
}
前面的 example13() 为每个操作数提供了两种备选方案:输出操作数的 “ab,cd” 和输入操作数的 “db,cam”。每个操作数都必须具有相同数量的备选方案;但是,每个备选方案都可以有任意数量的约束(例如,example13() 中的输出操作数的第二个备选方案有两个约束,而输入操作数的第二个备选方案有三个约束)。
编译器首先尝试满足第一个操作数的最左侧备选方案(例如,example13() 中的输出操作数)。在满足操作数时,编译器从最左侧的约束开始。如果编译器无法通过此约束满足备选方案(例如,如果所需的寄存器不可用),它将尝试使用任何后续约束。如果编译器用完了约束,它将移至下一个备选方案。如果编译器用完了备选方案,它将发出类似于 example12() 中提到的错误。如果找到备选方案,则编译器将对后续操作数使用相同的备选方案。例如,如果编译器为 example13() 中的输出操作数选择 “c” 寄存器,则它将对输入操作数使用 “a” 或 “m” 约束。
16.2.8. 约束修饰符
影响编译器对约束解释的字符称为约束修饰符。在 输出操作数 中介绍了两个约束修饰符 “=” 和 “+”。下表总结了每个约束修饰符。
约束修饰符 |
描述 |
---|---|
= |
此操作数是只写的。它仅对输出操作数有效。如果指定,则 “=” 必须作为约束字符串的第一个字符出现。 |
+ |
此操作数由指令读取和写入。它仅对输出操作数有效。在 asm 语句中的第一条指令之前,输出操作数使用其表达式进行初始化。如果指定,则 “+” 必须作为约束字符串的第一个字符出现。 |
& |
约束或备选约束(如 多种备选约束 中定义的)包含 “&” 表示输出操作数是提前覆写操作数。这种类型的操作数是输出操作数,它可能会在 asm 语句完成使用所有输入操作数之前被修改。编译器不会将此操作数放在可用作输入操作数或任何内存地址一部分的寄存器中。 |
% |
忽略。 |
# |
“#” 之后的字符直到第一个逗号(如果存在)将在约束中被忽略。 |
* |
“*” 之后的字符将在约束中被忽略。 |
“=” 和 “+” 修饰符应用于操作数,而与约束字符串中备选方案的数量无关。例如,example13() 的输出操作数中的 “+” 出现一次,并应用于约束字符串中的两个备选方案。“&”、“#” 和 “*” 修饰符仅应用于它们出现的备选方案。
通常,编译器假定输入操作数在将结果分配给输出操作数之前使用。此假设使编译器可以在 asm 语句内根据需要重用寄存器。但是,如果 asm 语句不遵循此约定,则编译器可能会随意使用输入操作数覆盖结果寄存器。为防止此行为,请应用提前覆写 “&” 修饰符。下面是一个示例
void example15()
{
int w=1;
int z;
asm( "movl $1, %0\n"
"addl %2, %0\n"
"movl %2, %1"
: "=a" (w), "=r" (z) : "r" (w) );
}
前面的代码示例提出了一个有趣的歧义,因为 “w” 同时作为输出和输入操作数出现。因此,“z” 的值可以是 1 或 2,具体取决于编译器是否对操作数 0 和操作数 2 使用相同的寄存器。对操作数 2 使用约束 “r” 允许编译器选择任何通用寄存器,因此它可能会(也可能不会)为操作数 2 选择寄存器 “a”。可以通过将操作数 2 的约束从 “r” 更改为 “a” 来消除此歧义,以便 “z” 的值为 2,或者通过添加提前覆写 “&” 修饰符,以便 “z” 的值为 1。以下示例显示了具有提前覆写 “&” 修饰符的相同函数
void example16()
{
int w=1;
int z;
asm( "movl $1, %0\n"
"addl %2, %0\n"
"movl %2, %1"
: "=&a" (w), "=r" (z) : "r" (w) );
}
添加提前覆写 “&” 强制编译器不要将 “a” 寄存器用于操作数 0 以外的任何内容。因此,操作数 2 将获得自己的寄存器,其中包含 “w” 的副本。example16() 中 “z” 的结果为 1。
16.3. 操作数别名
扩展 asm 在汇编字符串中使用百分号 ‘%’ 后跟操作数编号来指定操作数。例如,“%0” 引用操作数 0 或前一个示例中函数 example16() 中的输出项 “=&a” (w)。扩展 asm 还支持操作数别名,它允许使用符号名称而不是数字来指定操作数,如本示例所示
void example17()
{
int w=1, z=0;
asm( "movl $1, %[output1]\n"
"addl %[input], %[output1]\n"
"movl %[input], %[output2]"
: [output1] "=&a" (w), [output2] "=r"
(z)
: [input] "r" (w));
}
在 example18() 中,“%0” 和 “%[output1]” 都表示输出操作数。
16.4. 汇编字符串修饰符
汇编字符串中的特殊字符序列会影响编译器生成汇编代码的方式。例如,“%” 是用于指定操作数的转义序列,“%%” 生成硬编码寄存器的百分号,而 “\n” 指定新行。表 37 总结了这些修饰符,称为汇编字符串修饰符。
修饰符 |
描述 |
---|---|
\ |
与 printf 格式字符串中的 \ 相同。 |
%* |
在汇编字符串中添加 ‘*’。 |
%% |
在汇编字符串中添加 ‘%’。 |
%A |
在汇编字符串中操作数前面添加 ‘*’。(例如,%A0 在汇编输出中操作数 0 前面添加 ‘*’。) |
%B |
为此操作数生成字节操作码后缀。(例如,%b0 在 x86-64 上生成 ‘b’。) |
%L |
为此操作数生成字操作码后缀。(例如,%L0 在 x86-64 上生成 ‘l’。) |
%P |
如果生成位置无关代码 (PIC),编译器将为此操作数添加 PIC 后缀。(例如,%P0 在 x86-64 上生成 @PLT。) |
%Q |
如果目标支持四字,则为此操作数生成四字操作码后缀。否则,它将生成字操作码后缀。(例如,%Q0 在 x86-64 上生成 ‘q’。) |
%S |
为此操作数生成 ‘s’ 后缀。(例如,%S0 在 x86-64 上生成 ‘s’。) |
%T |
为此操作数生成 ‘t’ 后缀。(例如,%S0 在 x86-64 上生成 ‘t’。) |
%W |
为此操作数生成半字操作码后缀。(例如,%W0 在 x86-64 上生成 ‘w’。) |
%a |
在操作数周围添加开括号和闭括号 ()。 |
%b |
为操作数生成字节寄存器名称。(例如,如果操作数 0 在寄存器 ‘a’ 中,则 %b0 将生成 ‘%al’。) |
%c |
从立即操作数中剪切 ‘$’ 字符。 |
%k |
为操作数生成字寄存器名称。(例如,如果操作数 0 在寄存器 ‘a’ 中,则 %k0 将生成 ‘%eax’。) |
%q |
如果目标支持四字,则为操作数生成四字寄存器名称。否则,它将生成字寄存器名称。(例如,如果操作数 0 在寄存器 ‘a’ 中,则 %q0 在 x86-64 上生成 %rax。) |
%w |
为操作数生成半字寄存器名称。(例如,如果操作数 0 在寄存器 ‘a’ 中,则 %w0 将生成 ‘%ax’。) |
%z |
根据操作数的大小生成操作码后缀。(例如,字节为 ‘b’,半字为 ‘w’,字为 ‘l’,四字为 ‘q’。) |
%+ %C %D %F %O %X %f %h %l %n %s %y |
不支持。 |
这些修饰符以反斜杠 “\” 或百分号 “%” 开头。
以反斜杠 “\” 开头的修饰符(例如,“\n”)与在 printf 格式字符串中的效果相同。以 “%” 开头的修饰符用于修改特定操作数。
这些修饰符以反斜杠 “\” 或百分号 “%” 开头。例如,“%b0” 的意思是“生成操作数 0 的字节或 8 位版本”。如果操作数 0 是寄存器,它将生成字节寄存器,例如 %al、%bl、%cl 等。
16.5. 扩展 Asm 宏
与 内联汇编 中描述的传统内联汇编一样,扩展 asm 可以在宏中使用。例如,您可以使用以下宏来访问运行时堆栈指针。
#define GET_SP(x) \
asm("mov %%sp, %0": "=m" (##x):: "%sp" );
void example20()
{
void * stack_pointer;
GET_SP(stack_pointer);
}
GET_SP 宏将堆栈指针的值分配给插入到其参数中的任何内容(例如,stack_pointer)。另一种称为语句表达式的 C 扩展用于以另一种方式编写 GET_SP 宏
#define GET_SP2 ({ \
void *my_stack_ptr; \
asm("mov %%sp, %0": "=m" (my_stack_ptr) :: "%sp" ); \
my_stack_ptr; \
})
void example21()
{
void * stack_pointer = GET_SP2;
}
语句表达式允许代码体评估为单个值。此值指定为语句表达式中的最后一条指令。在这种情况下,该值是 asm 语句 my_stack_ptr 的结果。通过使用语句表达式编写 asm 宏,asm 结果可以直接分配给另一个变量(例如,void * stack_pointer = GET_SP2)或包含在更大的表达式中,例如:void * stack_pointer = GET_SP2 - sizeof(long)。
使用哪种宏风格取决于应用程序。如果 asm 语句需要成为表达式的一部分,那么带有语句表达式的宏是一个不错的方法。否则,传统的宏(如 GET_SP(x))可能就足够了。
16.6. 内联函数
内联内联函数映射到实际的 x86-64 机器指令。内联函数以内联方式插入,以避免函数调用的开销。编译器具有内联函数的特殊知识,因此与扩展内联汇编代码相比,使用内联函数可以生成更好的代码。
NVIDIA HPC 编译器内联函数库实现了 MMX、SSE、SS2、SSE3、SSSE3、SSE4a、ABM 和 AVX 指令。内联函数可用于 C 和 C++ 程序。与大多数库中的函数不同,内联函数在编译器内部实现。程序可以在包含相应的头文件后从 C/C++ 源代码调用内联函数。
内联函数分为以下头文件
指令 |
头文件 |
指令 |
头文件 |
|
---|---|---|---|---|
ABM |
intrin.h |
SSE2 |
emmintrin.h |
|
AVX |
immintrin.h |
SSE3 |
pmmintrin.h |
|
MMX |
mmintrin.h |
SSSE3 |
tmmintrin.h |
|
SSE |
xmmintrin.h |
SSE4a |
ammintrin.h |
以下是一个调用 XMM 内联函数的简单示例程序。
#include <xmmintrin.h>
int main(){
__m128 __A, __B, result;
__A = _mm_set_ps(23.3, 43.7, 234.234, 98.746);
__B = _mm_set_ps(15.4, 34.3, 4.1, 8.6);
result = _mm_add_ps(__A,__B);
return 0;
}
声明
注意
所有 NVIDIA 设计规范、参考板、文件、图纸、诊断程序、列表和其他文档(统称为“资料”)均按“原样”提供。 NVIDIA 对这些资料不作任何明示、暗示、法定或其他方面的保证,并且明确声明不承担任何关于不侵权、适销性和针对特定用途适用性的暗示保证。
所提供的信息据信是准确可靠的。但是,NVIDIA 公司对使用此类信息造成的后果或因使用此类信息而可能导致的侵犯第三方专利或其他权利的行为不承担任何责任。 NVIDIA 公司未通过暗示或其他方式授予任何专利权许可。本出版物中提及的规格如有更改,恕不另行通知。本出版物取代并替换以前提供的所有其他信息。未经 NVIDIA 公司明确书面批准,NVIDIA 公司产品不得用作生命支持设备或系统中的关键组件。
商标
NVIDIA、NVIDIA 徽标、CUDA、CUDA-X、GPUDirect、HPC SDK、NGC、NVIDIA Volta、NVIDIA DGX、NVIDIA Nsight、NVLink、NVSwitch 和 Tesla 是 NVIDIA Corporation 在美国和其他国家/地区的商标和/或注册商标。其他公司和产品名称可能是与其关联的各自公司的商标。