C++

基于代码的 C++ AMP 简介

Daniel Moth

下载代码示例

本文介绍将随同 Visual Studio 11 一同发布的名为 C++ AMP 的预发布技术。 所有信息均有可能发生变更。

Visual Studio 11 通过名为 C++ Accelerated Massive Parallelism (C++ AMP) 的技术为主流异构计算提供相应支持。 这使您能够利用 GPU 等加速器来加速数据并行算法。

C++ AMP 以硬件可移植方式提供性能,而不会影响您预期从新型 C++ 和 Visual Studio 软件包中获得的生产力。 与仅使用 CPU 相比,它可以数量级的速度提高计算能力。 在一些会议上,我通常演示同时利用 NVIDIA 和 AMD GPU 且仍采用 CPU 回退解决方案的单个过程。

在此探讨 C++ AMP 的代码驱动简介中,我将假定您阅读了本文中的每一行代码。 内嵌代码是本文的核心部分,并且本文不一定重复 C++ 代码中的内容。

设置和示例算法

首先,让我们了解将随必需设置代码一起使用的简单算法,以准备稍后转为使用 C++ AMP。

创建一个空 C++ 项目,添加新的空 C++ 文件 (Source.cpp) 并键入以下自解释代码(我使用非连续行号以方便在本文中进行解释,您将在附带的可下载项目中找到相同行号):

1 #include <amp.h>                // C++ AMP header file
3 #include <iostream>             // For std::cout etc
4 using namespace concurrency;    // Save some typing :)
5 using std::vector;     // Ditto.
Comes from <vector> brought in by amp.h
6
79 int main()
80 {
81   do_it();
82
83   std::cout << "Hit any key to exit..." << std::endl;
84   std::cin.get();
85 }

C++ AMP 跨许多头文件引入了众多类型。 根据上述代码段中的行 1 和 4,主头文件是 amp.h,并且将主类型添加到了现有并发命名空间中。 使用 C++ AMP 无需其他设置或编译选项。 现在让我们在主头文件之上添加 do_it 函数(参见图 1)。

图 1 从主头文件调用的 do_it 函数

52 void do_it()
53 {
54   // Rows and columns for matrix
55   const int M = 1024;
56   const int N = 1024;
57
58   // Create storage for a matrix of above size
59   vector<int> vA(M * N);
60   vector<int> vB(M * N);
61
62   // Populate matrix objects
63   int i = 0;
64   std::generate(vA.begin(), vA.end(), [&i](){return i++;});
65   std::generate(vB.begin(), vB.end(), [&i](){return i--;});
66
67   // Output storage for matrix calculation
68   vector<int> vC(M * N);
69
70   perform_calculation(vA, vB, vC, M, N);
76 }

在行 59、60 和 68 中,代码使用 std::vector 对象作为每个矩阵的平面容器,即使二维类型是您真正要处理的内容也是如此 — 稍后将对此进行详细说明。

必须了解行 64 和 65 上传递给用于填充两个矢量对象的 std::generate 方法的 lambda 表达式的用法。 本文假定您可以熟练地在 C++ 中使用 lambda。 例如,您应该立即了解变量 i 是否已由值捕获(通过修改捕获列表,例如此 [i] 或此 [=],并使用可变关键字),以及矢量的每个成员是否已初始化为 0! 如果您不能轻松使用 lambda(对 C++ 11 标准的最佳补充),请阅读 MSDN 库文章“Lambda Expressions in C++”(msdn.microsoft.com/library/dd293608) 并在阅读完后回到此处。

do_it 函数引入了对 perform_calculation 的调用,其编码如下:

7  void perform_calculation(
8    vector<int>& vA, vector<int>& vB, vector<int>& vC, int M, int N)
9  {
15   for (int i = 0; i < M; i++)
16   {
17     for (int j = 0; j < N; j++)
18     {
19       vC[i * N + j] = vA[i * N + j] + vB[i * N + j];
20     }
22   }
24 }

在此简化的矩阵加法示例中,值得注意的一件事是由于矩阵在矢量对象中的线性化存储而丢失了矩阵的多维性(这就是为什么您必须随矢量对象传入矩阵维度的原因)。 另外,您还必须使用行 19 上的指数执行所需计算。 如果您希望一起添加这些矩阵的子矩阵,则更需要这样做。

到目前为止,没有出现任何 C++ AMP 代码。 接下来,通过更改 perform_calculation 函数,您将了解如何可以开始引入一些 C++ AMP 类型。 在稍后部分中,您将了解如何充分利用 C++ AMP 并加快数据并行算法。

array_view<T, N>、extent<N> 和 index<N>

C++ AMP 引入了 concurrency::array_view 类型来包装数据容器 — 您可以将它视为智能指针。 它以矩形方式表示数据,在最不重要的维度中是连续的。 它存在的原因稍后将变得更加明显,接下来,您将了解其部分用法。 让我们更改 perform_calculation 函数体,如下所示:

11     array_view<int> a(M*N, vA), b(M*N, vB);
12     array_view<int> c(M*N, vC);
14
15     for (int i = 0; i < M; i++)
16     {
17       for (int j = 0; j < N; j++)
18       {
19         c(i * N + j) = a(i * N + j) + b(i * N + j);
20       }
22     }

在 CPU 上编译和运行的此函数具有与以前相同的输出。 唯一区别是无故使用了行 11 和 12 上引入的 array_view 对象。 行 19 仍具有所需指数(目前),但现在它使用 array_view 对象(a、b、c)而不是矢量对象(vA、vB 和 vC),并且它通过 array_view 函数运算符访问元素(而以前使用矢量下标运算符 — 稍后将对此进行详细说明)。

您必须通过模板参数(在此示例中为 int)告诉 array_view 它所包装的容器的元素类型;您将把该容器作为最后一个构造函数参数进行传递(例如,行 12 上矢量类型的 vC 变量)。 第一个构造函数参数是元素数。

您也可以使用 con­currency::extent 对象来指定元素数,以便可以按照如下所示更改行 11 和 12:

10     extent<1> e(M*N);
11     array_view<int, 1> a(e, vA), b(e, vB);
12     array_view<int, 1> c(e, vC);

extent<N> 对象表示多维空间,在其中将级别作为模板参数进行传递。 在此示例中,模板参数为 1,但级别可以是大于 0 的任何值。 extent 构造函数接受 extent 对象所表示的每个维度的大小,如行 10 上所示。 然后可以将 extent 对象传递给 array_view 对象构造函数来定义其形状,如行 11 和 12 上所示。 在这些行上,我还为 array_view 添加了第二个模板参数来指示它表示一维空间 — 正如在前面的代码示例中,我可以安全地忽略它,因为 1 是默认级别。

现在您了解了这些类型,可以进一步修改此函数以便它可以采用更自然的二维方式访问数据,这更加类似于矩阵:

10     extent<2> e(M, N);
11     array_view<int, 2> a(e, vA), b(e, vB);
12     array_view<int, 2> c(e, vC);
14
15     for (int i = 0; i < e[0]; i++)
16     {
17       for (int j = 0; j < e[1]; j++)
18       {
19         c(i, j) = a(i, j) + b(i, j);
20       }
22     }

对行 10-12 进行的更改使 array_view 对象成为二维的,因此我们将需要两个指数来访问一个元素。 行 15 和 17 通过下标运算符访问 extent 范围,而不是直接使用变量 M 和 N;在您将形状封装到 extent 中后,您现在可以在整个代码中使用该对象。

重要更改在行 19 上,您不再需要进行算术运算。 使用指数更加合情合理,这会使整个算法本身更加可读和可维护。

如果 array_view 是使用三维 extent 创建的,则函数运算符将需要三个整数才能访问一个元素,仍从最重要的维度到最不重要的维度。 因为您可能希望从多维 API 访问内容,还有一种方法可通过传递给其下标运算符的单个对象来为 array_view 设置指数。 该对象的类型必须是 concurrency::index<N>,其中 N 与创建 array_view 所使用的 extent 的级别相匹配。 您稍后将了解如何可以将 index 对象传递给代码,但现在让我们手动创建一个对象来了解它并查看其运行方式,方法是按照如下所示修改函数体:

10     extent<2> e(M, N);
11     array_view<int, 2> a(e, vA), b(e, vB);
12     array_view<int, 2> c(e, vC);
13
14     index<2> idx(0, 0);
15     for (idx[0] = 0; idx[0] < e[0]; idx[0]++)
16     {
17       for (idx[1] = 0; idx[1] < e[1]; idx[1]++)
18       {
19         c[idx] = a[idx] + b[idx];
//19         //c(idx[0], idx[1]) = a(idx[0], idx[1]) + b(idx[0], idx[1]);
20       }
22     }

正如您从行 14、15、17 和 19 中看到的,concurrency::index<N> 类型具有与 extent 类型非常类似的接口,只是 index 表示 N 维点而不是 N 维空间。 extent 和 index 类型都通过运算符重载支持众多算术运算 — 例如,上一个示例中所示的累加运算。

以前,循环变量(i 和 j)用于为 array_view 设置指数,而现在,它们可以由行 19 上的单个 index 对象取代。 该示例演示了如何通过使用 array_view 下标运算符,借助单个变量(在此示例中为 index<2> 类型的 idx)为该对象设置指数。

此时,您基本了解了随 C++ AMP 引入的三种新类型: array_view<T,N>、extent<N> 和 index<N>。 它们提供了其他功能,如图 2 中的类图表中所示。

array_view, extent and index Classes
图 2 array_view、extent 和 index 类

使用此多维 API 的真正动机是在数据并行加速器(例如 GPU)上执行算法。 为此,您需要 API 中的入口点来在加速器上执行代码,并且需要采用一种方法来在编译时检查您是否正在使用可在此类加速器上高效执行的 C++ 语言的子集。

parallel_for_each 和 restrict(amp)

指示 C++ AMP 运行时采用您的函数并在加速器上执行它的 API 是 concurrency::parallel_for_each 的新重载。 它接受两个参数: extent 对象和 lambda。

您已熟悉的 extent<N> 对象用于确定将在加速器上调用多少次 lambda,并且您应假定每次它将是调用您的代码的单独线程,可能并发执行,但没有任何顺序保证。 例如,extent<1>(5) 将导致对您传递给 parallel_for_each 的 lambda 调用 5 次,而 extent<2>(3,4) 将导致对 lambda 调用 12 次。 在实际算法中,通常可安排对 lambda 调用数千次。

lambda 必须接受您已熟悉的 index<N> 对象。 index 对象必须具有与传递给 parallel_for_each 的 extent 对象相同的级别。 当然每次调用 lambda 时,index 值会有所不同 — 这是区分 lambda 的两次不同调用的方法。 您可以将 index 值视为线程 ID。

以下是到目前为止我介绍的有关 parallel_for_each 的内容的代码表示形式:

89     extent<2> e(3, 2);
90     parallel_for_each(e,
91       [=](index<2> idx)
92       {
93         // Code that executes on the accelerator.
94         // It gets invoked in parallel by multiple threads
95         // once for each index "contained" in extent e
96         // and the index is passed in via idx.
97         // The following always hold true
98         //      e.rank == idx.rank
99         //      e.contains(idx) == true
100        //      the function gets called e.size() times
101        // For this two-dimensional case (.rank == 2)
102        //      e.size() == 3*2 = 6 threads calling this lambda
103        // The 6 values of idx passed to the lambda are:
104        //      { 0,0 } { 0,1 } { 1,0 } { 1,1 } { 2,0 } { 2,1 }
105      }
106    );
107    // Code that executes on the host CPU (like line 91 and earlier)

此简单代码的行 91 缺少重要内容,不会对该代码进行编译:

error C3577: Concurrency::details::_Parallel_for_each argument #3 is illegal: missing public member: 'void operator()(Concurrency::index<_Rank>) restrict(amp)'

在编写代码时,您可以随意在 lambda 体(行 92-105)中使用完整 C++ 语言(受 Visual C++ 编译器支持)所允许的任何内容。 但是,限制您在当前 GPU 体系结构上使用 C++ 语言的某些方面,因此您必须指示代码的哪些部分应遵循这些限制(以便您可以在编译时发现您是否违反了任何规则)。 必须对 lambda 以及您从 lambda 调用的任何其他函数签名做出该指示。 因此您必须按照如下所示修改行 91:

91         [=](index<2> idx) restrict(amp)

这是添加到 Visual C++ 编译器中的 C++ AMP 规范的关键新语言功能。 可以使用 restrict(cpu)(隐式默认值)或前面的代码示例中所示的 restrict(amp) 或结合使用这两者(例如,restrict(cpu, amp))对函数(包括 lambda)进行批注。 不存在其他选项。 批注成为函数签名的一部分,因此它参与重载,这是设计它的关键动机。 在使用 restrict(amp) 对函数进行批注时,将根据一组限制对函数进行检查,如果违反了限制,您将收到编译器错误。 以下博客文章中记录了一组完整限制: bit.ly/vowVlV

lambda 的 restrict(amp) 限制之一是它们不能通过引用捕获变量(参见接近本文末尾的说明),也不能捕获指针。 了解该限制后,在您查看 parallel_for_each 的上一个代码列表时,将想知道: “如果不能通过引用捕获,也不能捕获指针,我将如何观察 lambda 的结果,即所需的副作用? 在 lambda 完成后,我对通过值捕获的变量进行的所有更改不会提供给外部代码。”

该问题的答案是您已知道的一种类型: array_view。 允许在 lambda 中通过值捕获 array_view 对象。 它是传入和传出数据的机制。 只需使用 array_view 对象来包装实际容器,然后在 lambda 中捕获 array_view 对象以进行访问和填充,然后在调用 parallel_for_each 之后访问相应的 array_view 对象。

将所有代码合并在一起

使用您的新知识,您现在可以重新访问前面的串行 CPU 矩阵加法(使用了 array_view、extent 和 index 的加法),并按照如下所示替换行 15-22:

15     parallel_for_each(e, [=](index<2> idx) restrict(amp)
16     {
19       c[idx] = a[idx] + b[idx];
22     });

您看到行 19 保持不变,而在 extent 范围内手动创建了 index 对象的双嵌套循环替换为对 parallel_for_each 函数的调用。

当使用具有其自己的内存的离散加速器时,在 lambda 中捕获传递给 parallel_for_each 的 array_view 对象会导致将基础数据复制到加速器的全局内存中。 类似地,在 parallel_for_each 调用之后,当您通过 array_view 对象(在此示例中是通过 c)访问数据时,会将数据从加速器复制回主机内存。

您应该知道如果要通过原始容器 vC(而不是通过 array_view)访问 array_view c 的结果,则应该调用 array_view 对象的 synchronize 方法。 代码实际上将运行,因为 array_view 析构函数将代表您调用 synchronize,但这样会丢失所有异常,因此建议您显式调用 synchronize。 因此需要在 parallel_for_each 调用之后的任何位置添加一个语句,如下所示:

23          c.synchronize();

通过 refresh 方法可实现相反目的(确保 array_view 具有已更改的原始容器中的最新数据)。

更重要的是,(通常)跨 PCIe 总线复制数据会产生大量费用,因此您只需根据需要复制数据。 在前面的列表中,您可以修改行 11-13 以指示必须将 array_view 对象 a 和 b 的基础数据复制到加速器中(但不会复制回),还应指示无需将 array_view c 的基础数据复制到加速器。 所需更改在以下代码段中以粗体显示:

11          array_view<const int, 2> a(e, vA), b(e, vB);
12          array_view<int, 2> c(e, vC);
13          c.discard_data();

但是,即使进行了这些修改,矩阵加法算法的运算也不够密集,无法抵销复制数据的开销,因此实际上它不是使用 C++ AMP 实现并行化的好的候选方法。 我使用它只是为了教授您基础知识!

虽然如此,但通过在本文中使用此简单示例,您现在已具备并行化其他足以带来好处的计算密集型算法的技能。 下面的此类算法是一个矩阵乘法。 我没有添加任何注释,请确保您理解矩阵乘法算法的此简单串行实现:

void MatMul(vector<int>& vC, const vector<int>& vA,
  const vector<int>& vB, int M, int N, int W)
{
  for (int row = 0; row < M; row++)
  {
    for (int col = 0; col < N; col++)
    {
      int sum = 0;
      for(int i = 0; i < W; i++)
        sum += vA[row * W + i] * vB[i * N + col];
      vC[row * N + col] = sum;
    }
  }
}

… 以及相应的 C++ AMP 实现:

array_view<const int, 2> a(M, W, vA), b(W, N, vB);
array_view<int, 2> c(M, N, vC);
c.discard_data();
parallel_for_each(c.extent, [=](index<2> idx) restrict(amp)
{
  int row = idx[0]; int col = idx[1];
  int sum = 0;
  for(int i = 0; i < b.extent[0]; i++)
    sum += a(row, i) * b(i, col);
  c[idx] = sum;
});
c.synchronize();

在我的便携式计算机上,与 M=N=W=1024 的串行 CPU 代码相比,C++ AMP 矩阵乘法使性能提高了 40 余倍。

现在您已掌握所有基础知识,在使用 C++ AMP 实现算法后,您可能想知道如何选择要在其上执行算法的加速器。 接下来让我们介绍该内容。

accelerator 和 accelerator_view

并发命名空间的一部分是新的 accelerator 类型。 它表示系统上 C++ AMP 运行时可以使用的设备,第一次发布时,该设备为安装了 DirectX 11 驱动程序的硬件(或 DirectX 仿真器)。

当 C++ AMP 运行时启动时,它会枚举所有加速器,并根据内部启发式,选取其中一个作为默认加速器。 这就是为什么您在所有上述代码中无需直接处理加速器的原因 — 已为您选取了默认加速器。 如果您希望枚举加速器,甚至自己选择默认加速器,则可以非常轻松地实现此目的,如图 3 中的自解释代码所示。

图 3 选取加速器

26 accelerator pick_accelerator()
27 {
28   // Get all accelerators known to the C++ AMP runtime
29   vector<accelerator> accs = accelerator::get_all();
30
31   // Empty ctor returns the one picked by the runtime by default
32   accelerator chosen_one;
33
34   // Choose one; one that isn't emulated, for example
35   auto result =
36     std::find_if(accs.begin(), accs.end(), [] (accelerator acc)
37   {
38     return !acc.is_emulated; //.supports_double_precision
39   });
40   if (result != accs.end())
41     chosen_one = *(result); // else not shown
42
43   // Output its description (tip: explore the other properties)
44   std::wcout << chosen_one.description << std::endl;
45
46   // Set it as default ...
can only call this once per process
47   accelerator::set_default(chosen_one.device_path);
48
49   // ...
or just return it
50   return chosen_one;
51 }

在行 38 上,您可以看到查询许多加速器属性之一,而其他属性显示在图 4 中。

accelerator and accelerator_view Classes
图 4 accelerator 和 accelerator_view 类

如果您希望具有使用不同加速器的不同 parallel_for_each 调用,或由于任何其他原因,您希望比为过程全局设置默认加速器获得更明确的设置,则需要将 accelerator_view 对象传递给 parallel_for_each。 这是可能的,因为 parallel_for_each 具有接受 accelerator_view 作为第一个参数的重载。 获取 accelerator_view 对象与对 accelerator 对象调用 default_view 一样容易;例如:

accelerator_view acc_vw = pick_accelerator().default_view;

除 DirectX 11 硬件以外,C++ AMP 还提供了三个特殊加速器:

  • direct3d_ref: 用于正确性调试,但不用于生产,因为它比任何实际硬件慢很多。
  • direct3d_warp: 在当今使用多核并流式处理 SIMD 扩展的 CPU 上执行 C++ AMP 代码的回退解决方案。
  • cpu_accelerator: 在此版本中,根本不能执行 C++ AMP 代码。 它只用于设置暂存数组(高级优化技术),这超出了本文的讨论范围,但在以下博客文章中进行了介绍: bit.ly/vRksnn

自己了解平铺及其他参考资料

本文中没有介绍的最重要的主题是平铺。

从方案角度说,并且正如您使用迄今为止所探讨的编码技术所看到的,平铺的性能获得了极大提高,并(可能)提高更多。 从 API 角度说,平铺包括 tiled_index 和 tiled_extent 类型,以及 tile_barrier 类型和 tile_static 存储类。 还有接受 tiled_extent 对象且其 lambda 接受 tiled_index 对象的 parallel_for_each 的重载。 在该 lambda 中,允许您使用 tile_barrier 对象和 tile_static 变量。 我在我的第二篇 C++ AMP 文章的第 40 页中介绍了平铺 。

可以利用博客文章和联机 MSDN 文档,自己探讨其他主题:

  • <amp_math.h> 是一个具有两个命名空间的数学库,一个命名空间用于高精度数学函数,另一个用于快速但精确度稍差的数学函数。 可根据硬件功能和方案要求选择使用。
  • 提供了 <amp_graphics.h> 和 <amp_short_vectors.h> 以及一些 DirectX 互操作函数来处理图形编程。
  • concurrency::array 是绑定到加速器的一种容器数据类型,其接口与 array_view 几乎完全相同。 此类型是必须由 lambda 中传递给 parallel_for_each 的引用捕获的两种类型之一(另一种类型是 graphics 命名空间中的 texture)。 这是我在本文的前面部分提到的说明。
  • 支持 DirectX 内部函数,例如用于跨线程同步的原子。
  • Visual Studio 11 中的 GPU 调试和分析。

前瞻性地保护您的投资

在本文中,我向您介绍了新型 C++ 数据并行 API,利用它,您可以采用使您的应用程序能够利用 GPU 来提高性能的方式表述算法。 C++ AMP 的设计旨在前瞻性地保护您已进行的硬件投资。

您了解到如何通过将几种类型(array_view、extent 和 index)与一个允许您从 restrict(amp) lambda 开始执行代码的全局函数 (parallel_for_each) 结合使用,来在加速器(可以通过 accelerator 和 accelerator_view 对象指定)上处理多维数据。

除 Microsoft Visual C++ 实现以外,还将 C++ AMP 作为任何人都可以在任何平台上实现的开放规范提供给了社区。

Daniel Moth 是 Microsoft Developer Division 的首席项目经理。 可通过他的博客与其联系:danielmoth.com/Blog

衷心感谢以下技术专家对本文的审阅: Steve DeitzYossi LevanoniRobin Reynolds-HaertleStephen ToubWeirong Zhu