Tensorflow的StreamExecutor编程

瘦欲@ 提交于 2020-04-06 13:56:07

首先了解一下结构化编译器前端Clang。

背景与概览

Low Level Virtual Machine (LLVM) 是一个开源的编译器架构,它已经被成功应用到多个应用领域。Clang ( 发音为 /klæŋ/) 是 LLVM 的一个编译器前端,它目前支持 C, C++, Objective-C 以及 Objective-C++ 等编程语言。Clang 对源程序进行词法分析和语义分析,并将分析结果转换为 Abstract Syntax Tree ( 抽象语法树 ) ,最后使用 LLVM 作为后端代码的生成器。

Clang 的开发目标是提供一个可以替代 GCC 的前端编译器。与 GCC 相比,Clang 是一个重新设计的编译器前端,具有一系列优点,例如模块化,代码简单易懂,占用内存小以及容易扩展和重用等。由于 Clang 在设计上的优异性,使得 Clang 非常适合用于设计源代码级别的分析和转化工具。Clang 也已经被应用到一些重要的开发领域,如 Static Analysis 是一个基于 Clang 的静态代码分析工具。

本文将简单介绍 Clang 的背景知识和功能特性,并通过一个小例子介绍如何使用 Clang 的库来编写一个小程序来统计源代码中的函数。

Clang 的开发背景

由于 GNU 编译器套装 (GCC) 系统庞大,而且 Apple 大量使用的 Objective-C 在 GCC 中优先级较低,同时 GCC 作为一个纯粹的编译系统,与 IDE 配合并不优秀,Apple 决定从零开始写 C family 的前端,也就是基于 LLVM 的 Clang 了。Clang 由 Apple 公司开发,源代码授权使用 BSD 的开源授权。

Clang 的特性

相比于 GCC,Clang 具有如下优点:

  • 编译速度快:在某些平台上,Clang 的编译速度显著的快过 GCC。
  • 占用内存小:Clang 生成的 AST 所占用的内存是 GCC 的五分之一左右。
  • 模块化设计:Clang 采用基于库的模块化设计,易于 IDE 集成及其他用途的重用。
  • 诊断信息可读性强:在编译过程中,Clang 创建并保留了大量详细的元数据 (metadata),有利于调试和错误报告。
  • 设计清晰简单,容易理解,易于扩展增强。与代码基础古老的 GCC 相比,学习曲线平缓。

当前 Clang 还处在不断完善过程中,相比于 GCC, Clang 在以下方面还需要加强:

  • 支持更多语言:GCC 除了支持 C/C++/Objective-C, 还支持 Fortran/Pascal/Java/Ada/Go 和其他语言。Clang 目前支持的语言有 C/C++/Objective-C/Objective-C++。
  • 加强对 C++ 的支持:Clang 对 C++ 的支持依然落后于 GCC,Clang 还需要加强对 C++ 提供全方位支持。
  • 支持更多平台:GCC 流行的时间比较长,已经被广泛使用,对各种平台的支持也很完备。Clang 目前支持的平台有 Linux/Windows/Mac OS。

Clang 安装

在这一节,我们将介绍如何获取 Clang 源码,编译和安装 Clang。编译 Clang 要求您的系统中安装有 C++ 编译器 ( 如 GCC)。如果您还要编译 Clang 的测试集,那么您还需要事先安装 python。

获取源码

由于 Clang 是 LLVM 的一部分,并且 Clang 也用到 LLVM 的库,我们需要先下载 LLVM,然后下载 Clang 作为 LLVM 工具的一部分。下面的例子示意了如何 svn 在 Linux 下获取最新的 LLVM 和 Clang。

1 .创建 LLVM 源代码存放目录 (llvm_source)

$mkdir – p llvm_source

2 .进入创建的目录

$cd llvm_source

3 .获取 LLVM

$svn co http://llvm.org/svn/llvm-project/llvm/trunk llvm

4 .获取 Clang

$cd llvm/tools 
   $svn co http://llvm.org/svn/llvm-project/cfe/trunk clang

编译 Clang

$cd ../../ ( 返回 llvm_source) 
 $mkdir build ( 建立编译的工作目录 ) 
 $cd build 
 $../llvm/configure – prefix=$HOME/llvm ( 配置 LLVM,将目标安装目录设定为 $HOME/llvm) 
 $make ( 以 DEBUG 模式来编译 LLVM 和 Clang)

开始使用 Clang

您可以像使用普通的编译器一样使用 Clang。首先你需要把 Clang 的安装路径加入 PATH 环境变量中。以下例子假定您使用的是 Linux 的 bash:

$ export PATH=$HOME/llvm/bin:$PATH 
 $ export LD_LIBRARY_PATH=$HOME/llvm/lib/:$LD_LIBRARY_PATH 

在本文中,我们使用一个常见的 hello world 程序来演示 Clang。在这里我们把这个文件命名为 test.c。它的内容如下:
 #include <stdio.h> 
 int main(int argc, char **argv) 
 { 
    printf("hello world\n"); 
    return 0; 
 }

您可以使用任何编辑器输入并生成这个文件。

有了这个文件以后,您可以试试以下命令行的命令:

$ clang  --help ( 查看帮助信息 ) 
 $ clang test.c -fsyntax-only ( 检查语法和词法正确性 ) 
 $ clang test.c -S -emit-llvm -o test.bc ( 生成优化前的 llvm bitcode) 
 $ clang test.c -S -emit-llvm -o test.bc -O3 ( 生成优化的 llvm bitcode) 
 $ clang test.c -S -O3 -o test ( 生成可执行代码 )

与 GCC 相比,Clang 的一大优点是更加清晰明确的错误提示。

您不妨尝试着删除"printf("hello world\n");"语句后面的分号。编译这个程序,GCC 给出的错误信息将是:

test.c: In function 'main': 
 test.c:6:2: error: expected ';' before 'return'

而 Clang 给出的错误信息则是:

test.c:5:26: error: expected ';' after expression 
                printf("hello world.\n") 
                                            ^ 
                                            ;

相比之下,是不是 Clang 的错误信息更加明晰和用户友好呢?关于 Clang 出错处理更详细的信息,可以参见 http://clang.llvm.org/features.html#expressivediags

抽象语法树

前面我们提到过 Clang 是一个编译器前端,这也就是说:Clang 将目标程序进行分析,然后生成结构化的,树状的语法表示 , 即抽象语法树 AST(Abstract Syntax Tree)。

例如,下面简单的语句可以表示为语法树(如图 1):

while(x <= 5) 
 { 
 fun(x);
 }
图 1. 抽象语法树

将程序代码表示为抽象语法树的一个好处是能极大方便编译,分析和优化。比如,对于一个单纯的 C++ 程序来说,重命名一个变量就比较困难,我们不能简单地搜索变量名称替换成一个新的名称,因为这可能会改变太多。例如:不同的类或者名字空间里也有相同名称的变量名。将程序表示为抽象语法树以后,替换变量名称就会变得很简单:我们只需要改变对应的变量声明抽象语法树节点的名称字段,然后把抽象语法树转换回源代码,这是因为每个变量的定义都会对应一个独一无二的抽象语法树节点。

基于 Clang 的编程

与许多编译器前端相比,Clang 的最大特点就是良好的结构化设计,每部分都是一个单独的库 (library)。也就是说您可以单独的使用其中的一部分,来编写自己的程序。在这里,我们将通过一个小例子来介绍如何使用 Clang 的库来编写一个小程序来统计源代码中的函数。通过这个例子,您将对于如何遍历抽象语法树有一个基本的概念。

BoostConASTConsumer

我们的这个小程序将基于 Clang 的一个叫做 BoostConASTConsumer 的例子,这个例子的源代码位于:"tools/clang/lib/Frontend/BoostConAction.cpp"。

BoostConASTConsumer 的代码如下:

#include "clang/Frontend/FrontendActions.h"
 #include "clang/AST/ASTConsumer.h"
 #include "clang/AST/RecursiveASTVisitor.h"
 #include <cstdio> 
 #include <iostream> 
 using namespace clang; 
 namespace { 
    class BoostConASTConsumer : public ASTConsumer, 
    public RecursiveASTVisitor<BoostConASTConsumer> 
    { 
        public: 
        /// HandleTranslationUnit - This method is called when the 
        /// ASTsfor entire translation unit have been parsed. 
        virtual void HandleTranslationUnit(ASTContext &Ctx); 

        bool VisitCXXRecordDecl(CXXRecordDecl *D) 
       { 
           std::cout << D->getNameAsString() << std::endl; 
           return true; 
       } 
    }; 
 } 

 ASTConsumer *BoostConAction::CreateASTConsumer 
	 (CompilerInstance &CI, llvm::StringRef InFile) 
 { 
    return new BoostConASTConsumer(); 
 } 

 void BoostConASTConsumer::HandleTranslationUnit(ASTContext &Ctx) 
 { 
    fprintf(stderr, "Welcome to BoostCon!\n"); 
    TraverseDecl(Ctx.getTranslationUnitDecl()); 
 }

BoostConASTConsumer 是一个遍历抽象语法树 (AST) 的例子。当 Clang 读入源文件,Clang 将根据源程序的信息构造一棵抽象语法树。BoostConASTConsumer 就是一个 AST Consumer,提供了访问抽象语法树的接口。

我们可以看到:BoostConASTConsumer 是 ASTConsumer 的子类,同时也是 RecursiveASTVisitor 这个 C++ 模板的声明。其中对 ASTConsumer 的继承主要是重载了 HandleTranslationUnit 这个函数,这是 BoostConASTConsumer 的入口。当整个抽象语法树 (AST) 构造完成以后,HandleTranslationUnit 这个函数将会被 Clang 的驱动程序调用。

在本小节中,我们将着重介绍 RecursiveASTVisitor,这是一个重要的函数模板。通过介绍这个模板,我们将向您简单介绍遍历抽象语法树的一些基本概念。

RecursiveASTVisitor 是一个深度优先遍历 AST 和访问节点的类。对于一个已经构造好的语法树,它将完成以下三方面的工作:

  1. 遍历 AST 的每个节点;
  2. 在某一个节点,访问这个节点的层次结构 ( 每个节点也是一个树 );
  3. 如果某一个节点是一种类型的动态类别 ( 比如是一个子类等 ),调用一个用户重载的函数来访问这个节点;

上述工作由下面三组方法完成,分别是:

  1. TraverseDecl(Decl *x) 完成工作 1,它是遍历 AST 的入口。这个方法是用来访问有关变量和函数的声明。TraverseDecl 只是简单的根据节点的类型来调用相应的 TraverseFoo(Foo *x),然后递归访问 x 的子节点。TraverseStmt(Stmt *x) 和 TraverseType(QualType x) 则是用来访问一条语句和一个类型的(如结构体),它们的工作方式和 TraverseDecl 类似。
  2. WalkUpFromFoo(Foo *x) 完成工作 2。它不会尝试访问 x 的任何子节点,而是先调用 WalkUpFromBar(x),其中 Bar 是 Foo 的直接父类(除非 Foo 没有父类), 然后调用 VisitFoo(x)。
  3. VisitFoo(Foo *x)完成工作 3。

上述三组方法是分层次的 (Traverse* > WalkUpFrom * > Visit*)。一个方法 ( 如 Traverse*) 可以调用同一层次的方法 ( 例如其他 Traverse*) 或低一层次的方法 ( 如 WalkUpFrom*),它不能调用更高层次的方法。这个结构确保同样类型的 AST 节点会被同时访问,也就是说不会出现交替访问不同节点的情况。

下面的伪代码,简单描述了 TraverseDecl 的工作情况。假设我们有一个 AST 节点叫做 x。在入口处,TraverseDecl 将根据 x 的类型来调用相应的访问函数。比如,如果节点类型是一个函数声明,那么将调用 TraverseFunctionDecl。在 TraverseFunctionDecl 中则会递归调用函数的内容,如首先访问函数入口参数,然后访问函数体,在访问函数体的过程中又会调用 TraverseDecl 和 TraverseStmt 访问函数体内的变量声明和每一条语句。

switch(x->type()) 
 { 
 case FunctionDeclType: 
          TraverseFunctionDecl(dyn_cast<FunctionDecl>(x)); 
          break; 
    case DeclType: 
          TraverseDecl(dyn_cast<Decl>(x)); 
          break; 
    ... 
 }

统计源代码中的函数

明白了 BoostConASTConsumer 以后,我们就可以着手编写一个用来统计源代码中函数的例子了。我们所要做的工作其实很简单,只需要重载 VisitFunctionDecl。这样每当遇见一个函数的定义 (FunctionDecl) 的节点时,VisitFunctionDecl 将会被调用。

为了统计函数个数和信息,我们加入两个类数据成员:

int fc; // 用于统计函数个数
 std::vector<FunctionDecl*> funcs; // 用于记录已经遍历的函数

然后我们在 VisitFunctionDecl 里统计函数信息:
 bool VisitFunctionDecl(FunctionDecl *D) 
 { 
    if (D->isThisDeclarationADefinition()) // 只关心提供了定义的函数
                                                   //(忽略只有声明而没有在源代    
                                                   // 码中出现定义的函数)
    { 
        fc++; 
        funcs.push_back(D->getNameAsString());// 获取函数名并保存到 funcs 
    } 

    return true; 
 }

由于 HandleTranslationUnit 是函数的入口,因此我们在 HandleTranslationUnit 对变量 fc 进行初始化:

fc = 0;

最后,我们在析构函数里打印统计信息:

~BoostConASTConsumer() 
 { 
 std::cout << "I have seen " << fc << " functions. \ They are: " <<endl; 
    for (unsigned i=0; i<funcs.size(); i++) 
    { 
        std::cout << funcs[i] << endl; 
    } 
 }

编译

回到编译的工作目录 (build),重新编译以及安装 Clang:
 make install

测试 BoostConASTConsumer

回到 test.c 所在的目录,输入:
 $clang -cc1 -boostcon test.c 

我们将得到以下信息:
 I have seen 1 functions. They are: 
 main
然后翻一下 StreamExecutor

在Google,我们正在为CPU,GPU和其他平台的并行编程模型做大量工作。我们投入大量的一个地方是并行库,特别是与编译器技术(如运行时和数学库)密切相关的库。我们希望在开放式开发中,如果社区中的其他人感兴趣,那么这将是LLVM中的一个子项目。
最初,我们想开源我们的StreamExecutor运行库,用于简化加速器设备上数据并行工作流的管理,也可以扩展到支持其他硬件平台。我们希望使用Clang在定位CUDA并使用其他集成方式时使用StreamExecutor,但是如果它是LLVM项目的一部分,那么这一点更有意义。
但是,我们认为LLVM子项目应该仅仅是第一个实例来组织为一组具有StreamExecutor的几个库。作为如何创建统一的并行子项目可以帮助代码共享的一个例子,StreamExecutor库包含一些围绕CUDA驱动程序API和OpenCL API的漂亮的包装器,可以创建用于管理各种GPU设备的统一API。这种统一的GPU封装将广泛适用于需要与GPU设备通信的库。
当然,已经有一个用于并行运行时库的LLVM子项目:OpenMP!所以有一个如何适应这张照片的问题。最终,在此提议的新子项目中,将OpenMP项目作为库进行介绍可能是有意义的。特别是,OpenMP和StreamExecutor很有可能共享卸载到GPU并管理这些设备上的工作负载的代码。这将在下面的StreamExecutor文档的末尾进行讨论。然而,如果事实证明,OpenMP的需求太专业,不能很好地适应通用并行项目,那么将OpenMP作为单独的LLVM子项目可能是有意义的,因此它可以专注于满足OpenMP的特定需求。
正在提出用于开源的StreamExecutor库的文档包含以下内容,以便了解它是什么,以便为上下文提供如何适应一般并行LLVM子项目的上下文。
人们认为什么?对这样的事情有兴趣吗?如果是这样,我们可以开始努力获得一个项目,并绘制一个框架,以便如何组织,并为其提供StreamExecutor。我们很乐意迭代细节,找出对社区有用的内容。


什么是StreamExecutor?
StreamExecutor是CUDA和OpenCL主机端编程模型(运行时)的统一封装。 它允许主机代码使用具有相同功能的数据并行内核的CUDA或OpenCL设备。StreamExecutor管理与加速器同时工作的执行,与Google API客户端库中的执行程序如何管理主机上并发工作的执行情况相似。
StreamExecutor目前被用作绝大多数Google GPGPU应用程序的运行时,它的快照包含在开源TensorFlow项目中,作为GPGPU运行时。
目前提议StreamExecutor本身是独立开源的。 作为该提案的一部分,本文档介绍了其设计的基础知识,并解释了为什么它适合作为LLVM子项目。



关键点

StreamExecutor

     抽象底层加速器平台(避免将您锁定到单个供应商,并允许您编写代码,而不必考虑您将要运行的平台)。
     提供了CUDA运行时库的开放源代码。
     为用户提供了一个流程管理模型,该模型的术语与CUDA编程模型的术语匹配。
     利用现代C ++创建一个安全,高效,易于使用的编程接口。

StreamExecutor可以轻松实现:

     在主机和加速器之间(以及对等加速器之间)之间移动数据。
     执行以OpenCL或CUDA内核语言编写的数据并行内核。
     在运行时检查类似GPU的设备的功能。
     管理多个设备。














示例代码片段

StreamExecutor API使用与其他GPU API合作的人员所熟悉的抽象:Streams,Timers和Kernels。 它的API流畅,这意味着它允许用户将一系列相关操作链接在一个流中,如下面的代码片段所示:


se::Stream stream(executor);
se::Timer timer(executor);
stream.InitWithTimer(&timer)
    .ThenStartTimer(&timer)
    .ThenLaunch(se::ThreadDim(dim_block_x, dim_block_y),
                se::BlockDim(dim_grid_x, dim_grid_y),
                my_kernel,
                arg0, arg1, arg2)
    .ThenStopTimer(&timer)
    .BlockHostUntilDone();
在上面的代码段中启动的内核的名称是my_kernel,传递给内核的参数是arg0,arg1和arg2。支持具有任何类型的参数的内核,并在编译时检查参数的数量和类型。

它是如何工作的?
详细的例子

以下示例显示了如何使用StreamExecutor创建TypedKernel实例,将设备代码与该实例相关联,然后使用该实例来调度加速器设备上的工作。



#include <cassert>

#include "stream_executor.h"

namespace se = streamexecutor;

// A PTX string defining a CUDA kernel.
//
// This PTX string represents a kernel that takes two arguments: an input value
// and an output pointer. The input value is a floating point number. The output
// value is a pointer to a floating point value in device memory. The output
// pointer is where the output from the kernel will be written.
//
// The kernel adds a fixed floating point value to the input and writes the
// result to the output location.
static constexpr const char *KERNEL_PTX = R"(
    .version 3.1
    .target sm_20
    .address_size 64
    .visible .entry add_mystery_value(
        .param .f32 float_literal,
        .param .u64 result_loc
        ) {
      .reg .u64 %rl<2>;
      .reg .f32 %f<2>;
      ld.param.f32 %f1, [float_literal];
      ld.param.u64 %rl1, [result_loc];
      add.f32 %f1, %f1, 123.0;
      st.f32 [%rl1], %f1;
      ret;
    }
    )";

// The number of arguments expected by the kernel described in
// KERNEL_PTX_TEMPLATE.
static constexpr int KERNEL_ARITY = 2;

// The name of the kernel described in KERNEL_PTX.
static constexpr const char *KERNEL_NAME = "add_mystery_value";

// The value added to the input in the kernel described in KERNEL_PTX.
static constexpr float MYSTERY_VALUE = 123.0f;

int main(int argc, char *argv[]) {
  // Get a CUDA Platform object. (Other platforms such as OpenCL are also
  // supported.)
  se::Platform *platform =
      se::MultiPlatformManager::PlatformWithName("cuda").ValueOrDie();

  // Get a StreamExecutor for the chosen Platform. Multiple devices are
  // supported, we indicate here that we want to run on device 0.
  const int device_ordinal = 0;
  se::StreamExecutor *executor =
      platform->ExecutorForDevice(device_ordinal).ValueOrDie();

  // Create a MultiKernelLoaderSpec, which knows where to find the code for our
  // kernel. In this case, the code is stored in memory as a PTX string.
  //
  // Note that the "arity" and name specified here must match  "arity" and name
  // of the kernel defined in the PTX string.
  se::MultiKernelLoaderSpec kernel_loader_spec(KERNEL_ARITY);
  kernel_loader_spec.AddCudaPtxInMemory(KERNEL_PTX, KERNEL_NAME);

  // Next create a kernel handle, which we will associate with our kernel code
  // (i.e., the PTX string).  The type of this handle is a bit verbose, so we
  // create an alias for it.
  //
  // This specific type represents a kernel that takes two arguments: a floating
  // point value and a pointer to a floating point value in device memory.
  //
  // A type like this is nice to have because it enables static type checking of
  // kernel arguments when we enqueue work on a stream.
  using KernelType = se::TypedKernel<float, se::DeviceMemory<float> *>;

  // Now instantiate an object of the specific kernel type we declared above.
  // The kernel object is not yet connected with the device code that we want it
  // to run (that happens with the call to GetKernel below), so it cannot be
  // used to execute work on the device yet.
  //
  // However, the kernel object is not completely empty when it is created. From
  // the StreamExecutor passed into its constructor it knows which platform it
  // is targeted for, and it also knows which device it will run on.
  KernelType kernel(executor);

  // Use the MultiKernelLoaderSpec defined above to load the kernel code onto
  // the device pointed to by the kernel object and to make that kernel object a
  // handle to the kernel code loaded on that device.
  //
  // The MultiKernelLoaderSpec may contain code for several different platforms,
  // but the kernel object has an associated platform, so there is no confusion
  // about which code should be loaded.
  //
  // After this call the kernel object can be used to launch its kernel on its
  // device.
  executor->GetKernel(kernel_loader_spec, &kernel);

  // Allocate memory in the device memory space to hold the result of the kernel
  // call. This memory will be freed when this object goes out of scope.
  se::ScopedDeviceMemory<float> result = executor->AllocateOwnedScalar<float>();

  // Create a stream on which to schedule device operations.
  se::Stream stream(executor);

  // Schedule a kernel launch on the new stream and block until the kernel
  // completes. The kernel call executes asynchronously on the device, so we
  // could do more work on the host before calling BlockHostUntilDone.
  const float kernel_input_argument = 42.5f;
  stream.Init()
      .ThenLaunch(se::ThreadDim(), se::BlockDim(), kernel,
                  kernel_input_argument, result.ptr())
      .BlockHostUntilDone();

  // Copy the result of the kernel call from device back to the host.
  float host_result = 0.0f;
  executor->SynchronousMemcpyD2H(result.cref(), sizeof(host_result),
                                 &host_result);

  // Verify that the correct result was computed.
  assert((kernel_input_argument + MYSTERY_VALUE) == host_result);
}
内核加载器规格

MultiKernelLoaderSpec类的实例用于封装存储内核的设备代码的位置以及其所在格式的知识。给定一个MultiKernelLoaderSpec和一个未初始化的TypedKernel,调用StreamExecutor :: GetKernel方法会将代码加载到设备上并将TypedKernel实例与加载的代码相关联。 因此,为了初始化一个TypedKernel实例,首先需要创建一个MultiKernelLoaderSpec。

MultiKernelLoaderSpec支持为平台,格式和存储位置的每个组合添加设备代码的不同方法。 下表显示了一些例子:




Platform
  Format Location Setter
CUDA PTX disk AddCudaPtxOnDisk
CUDA PTX memory AddCudaPtxInMemory
CUDA cubin disk AddCudaCubinOnDisk
CUDA cubin memory AddCudaCubinInMemory
OpenCL text disk AddOpenCLTextOnDisk
OpenCL text memory AddOpenCLTextInMemory
OpenCL binary disk AddOpenCLBinaryOnDisk
OpenCL binary memory AddOpenCLBinaryInMemory

该示例中使用的具体方法是AddCudaPtxInMemory,但所有其他方法也使用类似。
编译器支持StreamExecutor
一般策略


为了说明的目的,示例中的PTX代码是手工编写的,并且在源代码文件中以字符串的形式出现,但是内核代码以CUDA C ++或OpenCL C等高级语言表示更为典型并且设备机器代码由编译器生成。


有几种方法可以使用StreamExecutor加载编译的设备代码。


一种可能性是构建系统可以将编译的设备代码写入磁盘上的文件。然后可以通过使用其中一个OnDisk设置器将其添加到MultiKernelLoaderSpec。


另一个选择是向编译器添加一个功能,该功能将编译的设备代码嵌入到主机可执行文件中,并提供一些符号(可能具有基于内核名称的名称),允许主机代码引用嵌入的代码数据。


实际上,如下所述,在Google目前使用StreamExecutor的情况下,编译器进一步发展,并为每个内核生成一个MultiKernelLoaderSpec实例。这意味着应用程序作者不必知道编译器如何或何地决定存储编译的设备代码,而是获取处理所有这些细节的预制加载器对象。
编译器生成的代码使事情安全


上述示例中的两个步骤是危险的,因为它们缺少静态安全检查:实例化MultiKernelLoaderSpec并专门化TypedKernel类模板。本节讨论如何编译器支持StreamExecutor可以使这些步骤安全。


实例化MultiKernelLoaderSpec需要指定三件事情:

    

内核概率(参数数),
    
内核名称,
    
一个包含内核的设备机器代码的字符串(作为程序集或某种目标文件)。


这样做的问题是内核名称和参数数量已经由内核的机器代码完全确定。在最佳情况下,传递给MultiKernelLoaderSpec方法的arity和name参数与机器代码中的信息相匹配,只是冗余,但在最坏的情况下,这些参数与机器代码中的信息相矛盾,当我们尝试加载内核..


第二个不安全的操作是将内核参数类型指定为TypedKernel类模板的类型参数。指定的类型必须与内核机器代码中定义的类型相匹配,但是再次没有这些类型匹配的编译时检查。不匹配这些类型将导致内核启动时的运行时错误。


我们希望编译器对应用程序作者执行这些检查,以消除这种运行时错误的来源。特别地,我们希望编译器为每个内核定义创建一个适当的MultiKernelLoaderSpec实例和TypedKernel专用化。


开源StreamExecutor的主要目标之一是让用户将此代码生成功能添加到Clang中,当用户选择使用StreamExecutor作为加速器操作的运行时时。


Google一直在使用基于Clang的内部开发的CUDA编译器,称为gpucc,以这种方式生成StreamExecutor的代码。下面的代码显示了如何使用gpucc编写上面的示例来生成代码的不安全部分。


内核在自己的文件中以高级语言(在本示例中为CUDA C ++)定义:

// File: add_mystery_value.cu

__global__ void add_mystery_value(float input, float *output) {
  *output = input + 42.0f;
}
主程序代码在另一个文件中定义:

// File: example_host_code.cc

#include <cassert>

#include "stream_executor.h"

// This header is generated by the gpucc compiler and it contains the
// definitions of gpucc::kernel::AddMysteryValue and
// gpucc::spec::add_mystery_value().
//
// The name of this header file is derived from the name of the file containing
// the kernel code. The trailing ".cu" is replaced with ".gpu.h".
#include "add_mystery_value.gpu.h"

namespace se = streamexecutor;

int main(int argc, char *argv[]) {
  se::Platform *platform =
      se::MultiPlatformManager::PlatformWithName("cuda").ValueOrDie();

  const int device_ordinal = 0;
  se::StreamExecutor *executor =
      platform->ExecutorForDevice(device_ordinal).ValueOrDie();

  // AddMysteryValue is an instance of TypedKernel generated by gpucc. The
  // template arguments are chosen by the compiler to match the parameters of
  // the add_mystery_value kernel.
  gpucc::kernel::AddMysteryValue kernel(executor);

  // gpucc::spec::add_mystery_value() is generated by gpucc. It returns a
  // MultiKernelLoaderSpec that knows how to find  the compiled code for the
  // add_mystery_value kernel.
  executor->GetKernel(gpucc::spec::add_mystery_value(), &kernel);

  se::ScopedDeviceMemory<float> result = executor->AllocateOwnedScalar<float>();
  se::Stream stream(executor);

  const float kernel_input_argument = 42.5f;

  stream.Init()
      .ThenLaunch(se::ThreadDim(), se::BlockDim(), kernel,
                  kernel_input_argument, result.ptr())
      .BlockHostUntilDone();

  float host_result = 0.0f;
  executor->SynchronousMemcpyD2H(result.cref(), sizeof(host_result),
                                 &host_result);

  assert((kernel_input_argument + 42.0f) == host_result);
}

编译器的这种支持使得StreamExecutor的使用安全无虞。

编译器支持三角支架内核启动
为了更方便使用,Google的gpucc CUDA编译器还支持一种集成模式,看起来像NVIDIA的CUDA编程模型,它使用三角括号(<<< >>>)启动内核。
在引擎盖下,gpucc将三角括号内核调用转换为与前面示例中看到的调用类似的StreamExecutor库的一系列调用。
Clang目前支持CUDA编译的三角括号内核调用语法,通过将三角形括号调用替换为对NVIDIA CUDA运行时库的调用,但是添加编译器标志可以轻松地向Clang发出对StreamExecutor库的调用在Clang中支持这种编译模式有几个好处:
    
StreamExecutor是一个高级的,现代化的C ++ API,因此与NVIDIA CUDA运行时和OpenCL运行时相比,易于使用且不易出错。
    
StreamExecutor将是开源软件,因此GPU代码将不必依赖于像NVIDIA CUDA运行时库这样的不透明的二进制Blob。
    
使用StreamExecutor作为运行时可以轻松扩展三角支架内核启动语法,以支持不同的加速器编程模型。


支持其他平台
StreamExecutor目前支持开箱即用的CUDA和OpenCL平台,但它使用平台插件架构,可以随时添加新平台。CUDA和OpenCL平台都以这种方式实现为平台插件,因此它们作为未来平台开发人员如何编写这些插件的良好示例。
罐装作业
StreamExecutor为通用数据并行操作提供了几个预定义的内核。支持的操作类是:
    
BLAS:基本线性代数子程序,
    
DNN:深层神经网络,
    
FFT:快速傅里叶变换,和
    
RNG:随机数生成。
以下是使用固定操作执行随机数生成的示例:
每个平台插件都可以为这些操作定义自己的canned操作插件,或者选择不使用任何未插入的插件。

与OpenMP对比
OpenMP的最新版本还提供了一个高级,易于使用的界面,用于在加速器设备上运行数据并行工作负载。OpenMP的方法和StreamExecutor的一个很大的区别是OpenMP生成在设备上运行的内核代码和启动内核所需的主机端代码,而StreamExecutor只生成主机端代码。虽然OpenMP模型提供了允许作者在标准C / C ++中编写其内核代码的便利,但StreamExecutor模型允许使用任何内核语言(例如CUDA C ++或OpenCL C)。这使得作者可以使用仅在平台特定的内核定义语言中使用的特定于平台的功能。
StreamExecutor的理念是,性能对设备至关重要,但在主机上则更少。因此,在设备代码生成期间,不会尝试使用高级设备抽象。相反,StreamExecutor提供的高级抽象仅用于移动数据和启动内核的主机端代码。这个主机端的工作是乏味的,并不是性能关键的,所以它受益于包装在一个可以轻松扩展的方式支持广泛的平台的高级库。
与OpenMP合作
Clang OpenMP社区目前正在设计其卸载支持的实现过程。他们希望编译器将各种标准化的面向目标的OpenMP编译指示转换为设备代码,以在加速器和主机代码上执行加载和运行该设备代码。StreamExecutor可以为OpenMP提供一个方便的API,用于生成其主机端代码。
除了StreamExecutor的所有用户享受替代主机端运行时库的好处之外,OpenMP和StreamExecutor可以通过共享工作来支持新平台而互利。如果OpenMP使用StreamExecutor,OpenMP将会为StreamExecutor将来支持的任何新平台添加支持。同样,对于OpenMP希望定位的任何平台,他们可以在StreamExecutor中添加该支持,并利用StreamExecutor社区中的平台支持知识。然后,所产生的新平台支持不仅可以在OpenMP中,还可以用于StreamExecutor的任何用户。
虽然OpenMP和StreamExecutor支持不同的编程模型,但它们在引擎盖下执行的一些工作可能会非常相似。通过分享代码和领域专长,随着能力的扩大,这两个项目都将得到改进和加强。 StreamExecutor社区期待与OpenMP进行许多协作和讨论,讨论最佳场所和合作方式。

这个不懂就了解了解了。

参考:

https://github.com/henline/streamexecutordoc

https://www.ibm.com/developerworks/cn/opensource/os-cn-clang/






发布了347 篇原创文章 · 获赞 635 · 访问量 266万+
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!