65.9K
CodeProject 正在变化。 阅读更多。
Home

OpenCL™ 入门教程

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.90/5 (10投票s)

2010年7月8日

Apache

9分钟阅读

viewsIcon

167193

本文旨在帮助您更轻松地理解和实现 OpenCL™。

Benedict R. Gaster,AMD 架构师,OpenCL™

OpenCL™ 是一项新兴技术,虽然规范已发布(www.khronos.org/registry/cl/),但目前很少有文档提供带有示例的基础介绍。本文旨在帮助您更轻松地理解和实现 OpenCL™。

请注意

  • 我在 AMD 工作,因此我将在 Windows® 和 Linux® 上测试我们实现的所有示例代码;然而,我的目的是说明 OpenCL™ 的使用,而不考虑平台。所有示例均使用纯 OpenCL™ 编写,应能在任何实现上良好运行。
  • 我已经尽力提供可在非 AMD OpenCL™ 实现上“开箱即用”的示例,但我不会在非 AMD 实现上进行测试;因此,示例在这些系统上可能无法按预期工作。如果发生这种情况,请通过我们的 Stream Computing 论坛告知我,我将尽最大努力纠正代码并发布更新。

以下“Hello World”教程提供了 OpenCL™ 的简单介绍。我希望在本次教程之后,继续推出更多涵盖以下主题的教程:

  • 使用平台和设备层构建健壮的 OpenCL™
  • 程序编译和内核对象
  • 管理缓冲区
  • 内核执行
  • 内核编程 - 基础
  • 内核编程 - 同步
  • 矩阵乘法 - 一个案例研究
  • 内核编程 - 内置函数

OpenCL™ 中的“Hello World”程序

以下是关于 OpenCL™ 示例如何编写的一些注意事项

  • OpenCL™ 规定了一个主机 API,该 API 定义为与 C89 兼容,并且不提及 C++ 或其他编程语言绑定。目前,有几项工作正在进行中,以开发其他语言的绑定(参见本文末尾的链接),并且特别地,一直以来都在大力推动开发 C++ 绑定。在本教程及后续教程中,我将仅使用 C++ 绑定,并以此类术语来描述 OpenCL™。有关相应的 C API,请参阅 OpenCL™ 1.0 规范。或者,您可以查看 C++ 绑定的源代码,以了解特定的 C++ 绑定使用了哪个底层 OpenCL™ 函数以及带有何种参数。

  • OpenCL™ 定义了一种类 C 语言,用于编程计算设备程序。这些程序通过 API 调用传递给 OpenCL™ 运行时,这些调用期望 char * 类型的值。通常,将这些程序保留在单独的源文件中更为方便。对于本教程及后续教程,我假设设备程序存储在文件名为 name_kernels.cl 的文件中,其中 name 会根据上下文而变化,但后缀 _kernels.cl 不会。相应的设备程序在运行时加载并传递给 OpenCL™ API。有许多其他方法可以实现这一点;这里选择这种方法是为了提高可读性。

对于这个第一个 OpenCL™ 程序,我们从主机应用程序的源代码开始。

头文件

与 C++ 中使用的任何其他外部 API 一样,在使用 OpenCL™ API 时必须包含一个头文件。通常,它位于主包含目录内的 CL 目录中。对于我们拥有的 C++ 绑定(将纯 C API 替换为 cl.h

#include <utility>

#define __NO_STD_VECTOR // Use cl::vector instead of STL version
#include <CL/cl.hpp>

对于我们的程序,我们使用少量额外的 C++ 头文件,这些头文件与 OpenCL™ 无关。

#include <cstdio>

#include <cstdlib>

#include <fstream>

#include <iostream>

#include <string>

#include <iterator>

由于我们将动态请求 OpenCL™ 设备返回“Hello World\n”字符串,因此我们将其定义为一个常量以用于计算。

const std::string hw("Hello World\n");

错误

大多数 OpenCL™ API 调用的一个共同属性是,它们要么将错误代码(cl_int 类型)作为函数本身的结果返回,要么将错误代码存储在用户作为参数传递给调用的位置。与任何可能失败的 API 调用一样,对于应用程序来说,正确检查其在错误情况下的行为非常重要。在大多数情况下,我们不会关心从错误中恢复;为了简单起见,我们定义一个函数 checkErr 来查看某个调用是否已成功完成。在这种情况下,OpenCL™ 返回 CL_SUCCESS 值。如果不是,它会输出一条用户消息并退出;否则,它将简单地返回。

inline void
checkErr(cl_int err, const char * name)
{
    if (err != CL_SUCCESS) {
        std::cerr << "ERROR: " << name
                 << " (" << err << ")" << std::endl;
        exit(EXIT_FAILURE);
    }
}

C++ 中错误处理的一个常见范例是通过使用异常,OpenCL™ C++ 绑定提供了这样的接口。稍后的教程将介绍异常和其他 C++ 绑定提供的可选功能。现在,让我们看看我们第一个 OpenCL™ 应用程序所需剩余的一个函数,“main”。

OpenCL™ 上下文

初始化和使用 OpenCL™ 的第一步是创建一个上下文。其余的 OpenCL™ 工作(创建设备和内存、编译和运行程序)在此上下文内执行。一个上下文可以有多个关联的设备(例如,CPU 或 GPU 设备),并且在上下文中,OpenCL™ 保证设备之间存在宽松的内存一致性。我们将在稍后的教程中详细介绍这一点;现在,我们使用单个设备 CL_DEVICE_TYPE_CPU,用于 CPU 设备。如果我们假设 OpenCL™ 实现支持该设备,我们也可以使用 CL_DEVICE_TYPE_GPU 或其他支持的设备类型。但在创建上下文之前,我们必须首先将 OpenCL™ 运行时排队,以确定存在哪些平台,即不同供应商的 OpenCL™ 实现。cl::Platform 类提供了静态方法 cl::Platform::get 来实现此目的,并返回一个平台列表。现在我们选择第一个平台并使用它来创建上下文。构造函数 cl::Context 应该成功,在这种情况下,err 的值为 CL_SUCCESS

int
main(void)
{
    cl_int err;
    cl::vector< cl::Platform > platformList;
    cl::Platform::get(&platformList);
    checkErr(platformList.size()!=0 ? CL_SUCCESS : -1, "cl::Platform::get");
    std::cerr << "Platform number is: " << platformList.size() << std::endl;
    
    std::string platformVendor;
    platformList[0].getInfo((cl_platform_info)CL_PLATFORM_VENDOR, &platformVendor);
    std::cerr << "Platform is by: " << platformVendor << "\n";
    cl_context_properties cprops[3] = 
        {CL_CONTEXT_PLATFORM, (cl_context_properties)(platformList[0])(), 0};
 
    cl::Context context(
       CL_DEVICE_TYPE_CPU, 
       cprops,
       NULL,
       NULL,
       &err);
    checkErr(err, "Conext::Context()");            

在深入研究“真正”工作发生的计算设备之前,我们首先分配一个 OpenCL™ 缓冲区来保存将在设备上运行的内核的结果,即字符串“Hello World\n”。目前,我们仅在主机上分配了一些内存,并要求 OpenCL™ 直接使用此内存,在创建缓冲区时传递标志 CL_MEM_USE_HOST_PTR

    char * outH = new char[hw.length()+1];
    cl::Buffer outCL(
        context,
        CL_MEM_WRITE_ONLY | CL_MEM_USE_HOST_PTR,
        hw.length()+1,
        outH,
        &err);
        checkErr(err, "Buffer::Buffer()");

OpenCL™ 设备

在 OpenCL™ 中,许多操作是针对给定上下文执行的。例如,缓冲区(内存的一维区域)和图像(内存的二维和三维区域)的分配都是上下文操作。但也有设备特定的操作。例如,程序编译和内核执行是按设备进行的,为此需要特定的设备句柄。那么我们如何获得设备的句柄呢?我们只需从上下文中查询即可。OpenCL™ 提供了将特定对象的有关信息排队的能力,使用 C++ API 时,它以 object.getInfo<CL_OBJECT_QUERY>() 的形式出现。在从上下文中获取设备的特定情况下

    cl::vector<cl::Device> devices;
    devices = context.getInfo<CL_CONTEXT_DEVICES>();
    checkErr(
        devices.size() > 0 ? CL_SUCCESS : -1, "devices.size() > 0");

现在我们有了上下文关联的设备列表,在本例中是一个 CPU 设备,我们需要加载并构建计算程序(我们打算在设备或更通用的设备上运行的程序)。以下代码的前几行只是从磁盘加载 OpenCL™ 设备程序,将其转换为字符串,并使用辅助构造函数创建一个 cl::Program::Sources 对象。给定一个 cl::Program::Sources 类型的对象,创建一个 cl::Program 对象并将其与上下文关联,然后为一组特定的 *设备* 构建它。

    std::ifstream file("lesson1_kernels.cl");
    checkErr(file.is_open() ? CL_SUCCESS:-1, "lesson1_kernel.cl");
 
    std::string prog(
        std::istreambuf_iterator<char>(file),
        (std::istreambuf_iterator<char>()));
 
    cl::Program::Sources source(
 
        1,
        std::make_pair(prog.c_str(), prog.length()+1));
 
    cl::Program program(context, source);
    err = program.build(devices,"");
    checkErr(file.is_open() ? CL_SUCCESS : -1, "Program::build()");

给定的*程序*可以有许多入口点,称为内核,要调用一个内核,我们必须构建一个内核对象。假定内核名称(表示为字符串)与计算程序中用 __kernel 属性定义的函数之间存在直接映射。在这种情况下,我们可以构建一个 cl::kernel 对象,即 kernel。内核参数使用 C++ API 通过 kernel.setArg() 设置,该函数接受特定参数的索引和值。

    cl::Kernel kernel(program, "hello", &err);
    checkErr(err, "Kernel::Kernel()");
 
    err = kernel.setArg(0, outCL);
    checkErr(err, "Kernel::setArg()");

现在样板代码已完成,是时候计算结果了(包含字符串“Hello World\n”的输出缓冲区)。所有设备计算都使用命令队列完成,命令队列是给定设备的虚拟接口。每个命令队列与给定设备都有一对一的映射;它是使用对 cl::CommandQueue 类构造函数的调用与关联的上下文创建的。给定一个 cl::CommandQueue *queue*,可以使用 queue.enqueuNDRangeKernel 排队*内核*。这会将一个*内核*排队以便在关联的设备上执行。内核可以在 1D、2D 或 3D 索引域上执行,如果资源足够,这些索引会并行执行。启动域中的元素(索引)总数称为 global 工作大小;单个元素称为 work-item。当需要 work-item 之间的通信时,可以将 Work-item 分组到 work-group 中。Work-groups 由子索引函数(称为 local 工作大小)定义,该函数描述了与全局启动域指定的维度相对应的每个维度的大小。内核启动有许多需要考虑的因素,我们将在未来的教程中更详细地介绍。目前,足以注意到对于 Hello World,每个 work-item 都计算结果字符串中的一个字母;并且足以启动 hw.length()+1,其中 hw 是我们在程序开头定义的 const std::string。我们需要额外的 work-item 来处理 NULL 终止符。

cl::CommandQueue queue(context, devices[0], 0, &err);
    checkErr(err, "CommandQueue::CommandQueue()");
 

    cl::Event event;
    err = queue.enqueueNDRangeKernel(
        kernel, 
        cl::NullRange,
        cl::NDRange(hw.length()+1),
         cl::NDRange(1, 1), 
        NULL, 
        &event);
    checkErr(err, "ComamndQueue::enqueueNDRangeKernel()");

上面 enqueueNDRangeKernel 调用的最后一个参数是 cl::Event 对象,可用于查询与之关联的命令的状态(例如,已完成)。它支持 wait() 方法,该方法会阻塞直到命令完成。这对于确保在将结果读回主机内存(使用 queue.enqueueReadBuffer())之前内核已完成执行是必需的。将计算结果读回主机内存后,只需将结果输出到 stdout 并退出程序。

    event.wait();    
    err = queue.enqueueReadBuffer(
        outCL,
        CL_TRUE,
        0,
        hw.length()+1,
        outH);
    checkErr(err, "ComamndQueue::enqueueReadBuffer()");
    std::cout << outH;
    return EXIT_SUCCESS;
}

最后,为了使程序完整,lesson1_kernels.cl 设备程序实现需要定义外部入口点 hello。内核实现很简单:它使用 get_global_id() 根据启动域计算一个唯一的索引,将其用作 hw 字符串的索引,然后将其值写入输出数组 out

#pragma OPENCL EXTENSION cl_khr_byte_addressable_store : enable
__constant char hw[] = "Hello World\n";
__kernel void hello(__global char * out)
{
    size_t tid = get_global_id(0);
    out[tid] = hw[tid];
}

为了健壮性,最好检查线程 ID (tid) 是否在 hw 的范围内;目前,我们假设对 queue.enqueueNDRangeKernel() 的相应调用是正确的。

构建和运行

在 Linux 上,使用一个命令构建 OpenCL™ 程序就足够了;例如
gcc –o hello_world –Ipath-OpenCL-include –Lpath-OpenCL-libdir lesson1.cpp –lOpenCL

运行
LD_LIBRARY_PATH=path-OpenCL-libdir ./hello_world

在 Windows 上,使用 Visual Studio 命令窗口,示例如下
cl /Fehello_world.exe /Ipath-OpenCL-include lesson.cpp path-OpenCL-libdir/OpenCL.lib

假设 OpenCL.dll 在路径中,然后运行
.\hello_world

会在 stdout 上输出以下字符串
Hello World

这完成了我们的 OpenCL™ 入门教程。我们请求您的反馈、评论和问题。请访问我们的 Stream 论坛

有用链接

以下列表提供了除 C 之外的 OpenCL™ 特定编程绑定的链接。我尚未测试过这些绑定,也无法保证其正确性,但希望它们会有所帮助

OpenCL 和 OpenCL 徽标是 Apple Inc. 的商标,经 Khronos 许可使用。
© . All rights reserved.