使用 Fortran DO CONCURRENT 进行加速器卸载
本文的目的是评估其在异构并行方面的优缺点。
如果你需要将公式(FORMULA)翻译成代码,FORTRAN 是一个不错的选择,并且在过去的 66 年里一直如此。我们可以争辩说,它是数学领域原始的领域特定语言,但这留待以后讨论。本文的目的不是颂扬 Fortran 的诸多优点,而是评估其在异构并行方面的优缺点。
基于标准的编程语言为我们提供了一种通用的方言来表达算法。然而,它们对专用硬件的支持往往滞后,正如我们在 SYCL 的优势:为什么 ISO C++ 不足以应对异构计算 中所见。让我们看看 ISO Fortran 在异构计算方面支持得如何,或许可以为洛斯阿拉莫斯国家实验室最近的一份报告所引发的争论增添一些内容:《评估在未来 15 年依赖 Fortran 编写关键任务代码的风险》。
DO CONCURRENT 结构在 ISO Fortran 2008 中引入,并在更近期的 ISO 标准中得到了增强。它告知或断言编译器,DO CONCURRENT 循环的迭代是独立的,可以并行执行。Intel® Fortran 编译器支持 DO CONCURRENT。DO CONCURRENT 循环可以顺序执行,也可以并行执行,甚至可以使用 OpenMP* 后端将 DO CONCURRENT 循环卸载到加速器。
我们将使用一个简单的图像分割算法来演示此功能。该算法检测图像中对象的边缘(**图 1**)。这个高通滤波器是许多计算机视觉过程的第一步,因为边缘包含了图像中的大部分信息。**图 1** 中的插图显示了一个包含三个对象的二值图像,由一组 1 表示。边缘掩码是一个布尔矩阵,其中 true 表示相应的“像素”位于对象边缘上。
Fortran 提供了方便的数组表示法和内建过程,可以轻松地对边缘检测进行编码(**图 2**)。我们可以通过将一个 9 点二值滤波器应用于每个像素来实现此算法。由于 DO CONCURRENT 循环的谓词,该滤波器仅应用于属于对象的像素。每个像素上的操作是独立的,因此该算法具有高度数据并行性,并且易于使用 Fortran DO CONCURRENT 循环和几行代码来实现。
integer, allocatable :: 1mage(:,:)
logical, allocatable :: edge_mask(:,:)
! Allocate image and edge mask
allocate (image(n, n), source = 0, stat = allocstat, errmsg = allocmsg)
allocate (edge_mask(n, n), source = .false., stat = allocstat, errmsg = allocmsg)
! Initialize image
! Outline the objects in the binary image
do concurrent (j = 1:n, i = 1:n, image(i, j) /= 0)
if (i == 1 .or. i == n .or. &
j == 1 .or. j == n) then
edge_mask(i, J) = .true.
else
if (any(image(i-1:i+1, j-1:j+1) == 0)) edge_mask(i, j) = .true.
endif
enddo
DO CONCURRENT 结构只是 DO 结构的一种形式。即使这是您第一次看到 DO CONCURRENT,对于大多数 Fortran 程序员来说,应该很清楚,这个例子像熟悉的双重嵌套 DO 循环一样,循环遍历 i
和 j
索引。与 DO CONCURRENT 结构可能新颖之处在于可选的谓词。这是一个标量掩码表达式,类型为 LOGICAL。如果存在谓词,则仅执行掩码表达式为 TRUE 的那些迭代。上面的 DO CONCURRENT 代码在功能上等同于以下 DO 和 IF 实现:
do j = 1, n
do i = 1, n
if (image(i, j) /= 0) then
! Same loop contents
endif
enddo
enddo
主要区别在于 DO CONCURRENT 向编译器声明没有依赖关系,因此迭代可以按任何顺序执行。
Intel Fortran 编译器可以使用 OpenMP 后端并行化和/或卸载 DO CONCURRENT 循环中的语句。这在编译示例代码的命令中显而易见。
第一个可执行文件 (img_seg_do_conc_cpu
) 将在所有可用的主机处理器上并行运行 DO CONCURRENT 循环。第二个可执行文件 (img_seg_do_conc_gpu
) 将把计算卸载到加速器设备。主机-设备数据传输由 OpenMP 运行时隐式处理。与 ISO C++ 一样,ISO Fortran 2018 没有不相交内存的概念,因此没有语言结构来控制数据传输。这对程序员来说很方便,因为它简化了编码。运行时将必要的数据复制到设备,然后在 DO CONCURRENT 循环执行完成后将所有数据复制回主机。如果我们使用单个 1000 x 1000 图像运行示例程序,其中有 10 个对象的随机分布,这是 OpenMP 运行时的一个调试输出。
我们高亮显示了图像和边缘掩码数组。每个数组为 1000 x 1000 x 4 字节 = 4,000,000 字节,可以看到它们都从 hst
→tgt
和 tgt
→hst
传输,总共在主机 (hst
) 和目标 (tgt
) 设备之间传输了 16,000,000 字节。(未高亮的 88 字节数据移动是 Fortran 数组描述符,或称为 dope vectors,是映射到目标设备的数组的描述符。我们可以忽略这些数据移动,因为数组描述符通常很小。)
尽管隐式主机-设备数据传输很方便,但并非总是高效。请注意,在 DO CONCURRENT 循环体(**图 2**)中并未修改图像变量。它仅在设备上读取,因此不需要传输回主机。在不相交内存之间移动数据需要时间和能量,因此在异构并行计算中,最小化主机-设备数据传输是首要的关注点。不幸的是,ISO Fortran 2018 和即将发布的 2023 标准没有提供控制数据移动的语言结构。
OpenMP 目标卸载 API 提供了显式控制主机-设备数据传输的结构(**图 3**)。OpenMP 实现的边缘检测算法将图像传输到设备 [map(to:image)
],但仅将边缘掩码传输回主机 [map(from:edge_mask)
]
再次高亮显示图像和边缘掩码数组。您可以看到图像(4,000,000 字节)从 hst
→tgt
传输,边缘掩码(4,000,000 字节)从 tgt
→hst
传输,总共只传输了 8,000,000 字节。DO CONCURRENT 代码(**图 2**)的数据移动量是 OpenMP 目标卸载代码(**图 3**)的两倍。
! Outline the objects in the binary image
!$omp target data map(to:image) map(from:edge_mask)
!$omp target
!$omp parallel do
do j = 1, n
do i = 1, n
edge_mask(i, j) = .false.
if (image (i, j) /= 0) then
if (i == 1 .or. i == n .or. &
j == 1 .or. j == n) then
edge_mask(i, j) = .true.
else
if (any(image(i-1:i+1, j-1:j+1) == 0)) edge_mask(i, j) = .true.
endif
endif
enddo
enddo
!$omp end target
!$omp end target data
此示例中的简单边缘检测器所执行的工作量不足以进行加速器卸载。我们使用简单的 3x3 点滤波器对二值图像进行了图像分割。更复杂的滤波器、真实图像和/或体积图像将更计算密集型和/或数据密集型,这将影响性能和卸载特性。我们将在下一篇文章中对更真实的边缘检测器(例如 Sobel 滤波器)和真实图像进行基准测试。但是,根据经验,我们知道不必要的主机-设备数据传输会限制性能。这在之前的《使用 oneMKL 和 OpenMP 目标卸载解决线性系统》中已有所展示。
这是一个“好消息,坏消息”的情况。好消息是 ISO Fortran 代码(**图 2**)可以在加速器上运行。让运行时隐式处理主机-设备数据传输对于许多算法来说是可以接受的。坏消息是,对只读图像进行边缘检测不属于这种情况。没有办法显式控制数据传输,因此不可避免的会发生不必要的数据传输。这可能会限制异构并行性能。幸运的是,OpenMP 目标卸载 API 在需要时提供了显式控制。
您可以在免费的 Intel® Developer Cloud 上尝试 Fortran DO CONCURRENT 和 OpenMP 加速器卸载,该平台拥有最新的 Intel® 硬件和软件。