为什么 ISO C++ 不足以满足异构计算的需求
在本文中,我们将探讨为什么 C++ 结合 SYCL 是加速计算的最佳方法,以及为什么 C++ 标准是至关重要的基础,但对于加速计算的需求来说却不足够。
为什么 ISO C++ 不足以满足异构计算的需求
一旦你在计算科学领域工作足够长的时间,你就会知道编程语言和工具会不断出现和消失。现在很少有人能奢侈地只使用一种编程语言。当我 2000 年加入英特尔时,我的项目混合了 Fortran、C,以及令我沮丧的 C++。那时我并不喜欢 C++。它编译需要很长时间,我没看到有多少对象或面向对象编程的意义,而且使语言表达力强的那些构造也让它变慢了。在过去 20 年里,C++ 编译器已经有了显著的改进,所以这种情况不再存在了。
因此,当我开始使用 oneAPI 时,我对不得不重新学习 C++ 并不感到兴奋。oneAPI 的直接编程方法是Khronos SYCL,它基于 ISO C++17。然而,令我惊讶的是,C++17 与 2000 年左右的 C++ 相比看起来多么不同。它仍然有些晦涩,但却非常富有表现力,我甚至敢说:具有生产力,尤其是在与 C++ 标准模板库 (STL) 和 oneAPI 库结合使用时,这让我想到我们今天要讨论的主题:SYCL 及其与 ISO C++ 的关系。
C++ 是一种优秀的编程语言(尽管 Rust 的倡导者们可能不这么认为——可以搜索 C++ vs. Rust 来感受一下这场辩论),它经受住了时间的考验。有很多 C++ 程序员和数十亿行的 C++ 代码。然而,Herb Sutter 所说的“免费午餐”早已结束,他现在谈论的是“硬件丛林”。我们已经深入到异构并行计算的时代,但 C++ 缺乏利用加速器设备所需的关键功能。“免费午餐”指的是从频率扩展到核心扩展的转变。“硬件丛林”指的是专用处理器的大量涌现。
首先,也是最重要的一点,C++ 没有加速器设备的这个概念,所以显然没有语言构造来发现可用的设备、将工作卸载到这些设备,或者处理在设备上运行的代码中发生的异常。如果代码在主机和各种设备上异步运行,异常处理会变得特别复杂。SYCL 提供了 C++ 的platform、context和device类来发现可用的设备,并查询这些设备的硬件和后端特性。queue和event类允许程序员将工作提交给设备并监视异步任务的完成。如果设备上运行的代码发生错误,可以通过queue::wait_and_throw()
、queue::throw_asynchronous()
或event::wait_and_throw()
方法,或者在队列或上下文销毁时自动触发异步错误处理程序。
其次,并且仅次于前者,C++ 没有分离内存(disjoint memories)的概念。加速器设备通常有自己的内存,与主机系统的内存是分开的。异构并行计算需要一种方法将数据从主机内存传输到设备内存。C++ 没有提供这样的机制,但 SYCL 以buffers/accessors以及分配到统一共享内存 (USM) 的指针形式提供了这种能力。主机-设备数据传输是使用 C++ 类和/或熟悉的动态内存分配语法完成的。
C++ 与 SYCL
SYCL 为 C++ 添加了功能以支持加速器设备。它只是添加了一套新的 C++ 模板库(图 1)。下面的并排示例说明了这一点,它分别用 C++(左侧)和 SYCL(右侧)实现了常见的 SAXPY(单精度 A 乘以 X 加上 Y)计算。
#include <vector>
int main()
{
size_t N{...};
float A{2.0};
std::vector<float> X(N);
std::vector<float> Y(N);
std::vector<float> Z(N);
for (int i = 0; i < N; i++)
{
// Initialize the X, Y,
// and Z work arrays.
}
for (int i = 0; i < N; i++)
{
Z[i] += A * X[i] + Y[i];
}
}
| #include <CL/sycl.hpp>
int main()
{
size_t N{...};
float A{2.0};
sycl::queue Q;
auto X = sycl::malloc_shared<float>(N, Q);
auto Y = sycl::malloc_shared<float>(N, Q);
auto Z = sycl::malloc_shared<float>(N, Q);
for (int i = 0; i < N; i++)
{
// Initialize the X, Y,
// and Z work arrays.
}
Q.parallel_for(sycl::range<1>{N}, [=](sycl::id<1> i)
{
Z[i] += A * X[i] + Y[i];
});
Q.wait();
}
|
代码显然很相似。C++ 代码只是经过修改,以便将 SAXPY 计算卸载到加速器。
- 第 1 行:包含 SYCL 头文件。
- 第 8 行:为默认加速器创建一个 SYCL 队列。如果没有可用的加速器,提交到队列的任何工作都将在主机上运行。
- 第 9-11 行:在 USM 中分配工作数组。SYCL 运行时将处理主机和队列对象中指定的加速器之间的数据传输。
- 第 19 行:将 SAXPY 计算作为 C++ lambda 函数提交,以在队列参数中指定的加速器上并行执行。
- 第 23 行:与某些专有的异构并行编程方法不同,SYCL 是一种异步、基于事件的语言。提交工作到 SYCL 队列不会阻止主机(或其他加速器)继续执行有用的工作。wait() 方法可防止主机在加速器完成 SAXPY 计算之前退出。
高效的开发方法
我之前曾说过 oneAPI 是一种高效的加速计算方法。我用下一个示例来证明这一点。STL 极大地提高了 C++ 程序员的开发效率。oneAPI 数据并行 C++ 库 (oneDPL) 是 C++ STL 的 oneAPI 加速实现。下面的并排示例使用 C++ STL(左侧)和 oneDPL(右侧)执行 maxloc 归约。
#include <iostream>
#include <vector>
#include <algorithm>
int main()
{
std::vector<int> data{2, 2, 4, 1, 1};
auto maxloc = max_element(
data.cbegin(),
data.cend());
std::cout << "Maximum at element "
<< distance(data.cbegin(),
maxloc)
<< std::endl;
}
| #include <iostream>
#include <oneapi/dpl/algorithm>
#include <oneapi/dpl/execution>
#include <oneapi/dpl/iterator>
int main()
{
std::vector<int> data{2, 2, 4, 1, 1};
auto maxloc = oneapi::dpl::max_element(
oneapi::dpl::execution::dpcpp_default,
data.cbegin(),
data.cend());
std::cout << "Maximum at element "
<< oneapi::dpl::distance(data.cbegin(),
maxloc)
<< std::endl;
}
|
再次强调,代码很相似。
- 第 2-4 行:包含必要的 oneDPL 头文件。
- 第 9 行和第 15 行:调用 STL 的 max_element() 和 distance() 函数的 oneDPL 实现。
- 第 10 行:提供一个执行策略,在这种情况下是默认加速器的策略。如果没有可用的加速器,提交到队列的任何工作都将在主机上运行。
oneDPL 代码中没有显式的主机-设备数据传输。它由运行时隐式处理,这加强了我关于 oneAPI 提供了一种高效、可移植的异构加速方法的观点。值得注意的是,SYCL 程序可以在任何安装了 SYCL 编译器和运行时的系统上运行(图 2)。
C++ 是一种具有根深蒂固的程序员期望和稳定性要求的语言,因此对标准进行的更改都非常谨慎,尽管进展缓慢。异构并行计算只是未来 C++ 版本必须支持的众多新功能之一。P2300R5 std::execution 提案通过定义一个“…用于在通用执行上下文中管理异步执行的框架”和一个“…基于...调度器、发送者和接收者...的异步模型”,可以帮助解决 C++ 在异构性方面的当前限制。该提案已提交给 C++26 的库工作组。与此同时,请注意图 2 右上角的注释。在 ISO C++ 标准不断发展的同时,SYCL 为我们提供了一种灵活且非专有的方式来表达异构并行计算。
了解更多
要了解更多关于 C++ 与 SYCL 编程的知识,以下资源是不错的起点。
- sycl.tech 是关于 SYCL 的一站式最佳网站。
- 《SYCL 2020 规范》和《SYCL 2020 API 参考指南》是我编写 SYCL 代码时的首选资源。
- 我上面简要提到了 oneAPI,并声称它是一种高效的编程方法。请看“使用 oneAPI 实现傅里叶相关算法”(文章,网络研讨会,源代码),了解我为何说 oneAPI 是一种高效的编程方法。我将展示如何在几行 SYCL 和 oneMKL 代码中加速一个复杂的算法。
- 我简要介绍了使用 SYCL buffers 和 accessors 进行主机-设备数据传输。在 oneDPL 的背景下,“oneAPI 中的 Maxloc 归约”(文章,网络研讨会,源代码)通过隐式缓冲、显式缓冲和 USM 来说明了主机-设备数据传输。
- 我还简要介绍了异步、异构并行 SYCL 代码中的异常处理。幸运的是,《数据并行 C++:使用 C++ 和 SYCL 掌握异构系统编程》一书的第 5 章:错误处理提供了很好的概述。我强烈推荐这本书给任何想学习 SYCL 的人。
- James Reinders 在 HPCwire 上发表的关于使用 SYCL 解决异构编程挑战的文章。
- 在线教程“SYCL 基础知识”。
- 《oneAPI 示例》仓库包含数十个示例代码,用于说明 SYCL 和 oneAPI 编程。