前言
本文是对jupyterlab中oneAPI_Essentials/02_SYCL_Program_Structure文档的学习记录,包含对Device Selector、Data Parallel Kernel、Host Accessor、Buffer Destruction、的介绍,最后还有一个小关于向量(Vector)加法的实例
设备(Device)
设备类包含用于查询设备信息的成员函数,这对于创建多个设备的SYCL程序很有用,成员函数get_info,可以获取包括以下的信息:
名称,供应商,版本号,本地和全局工作编号,宽度内置类型,时钟频率,缓存宽度和大小,在线或离线等
设备选择器(Device Selector)
这个类允许在运行时根据用户提供的启发式方法选择特定设备来执行内核。下面的代码示例显示了标准设备选择器的使用
运行结果
队列(Queue)
队列类提交要由SYCL运行时执行的命令组。队列是一种将工作提交给设备的机制。一个队列映射到一个设备,多个队列可以映射到同一个设备
内核(Kernel)
内核对象不是由用户显式构造的,而是在调用parallel_for等内核调度函数时构造的,用于在设备上执行代码的方法和数据
Scope
Application scope和command group scope
执行在主机上的代码,在这个scope中可以使用c++全部代码
Kernel scope
执行在设备(Device)上的代码,这个scope中可以无法使用部分c++的功能
Parallel Kernels
并行内核允许一个操作的多个实例并行执行。这对于offload的基本for循环的并行执行非常有用,因为for循环中的每个迭代都是完全独立的,并且按任意顺序执行。并行内核是用parallel_for函数表示的。c++应用程序中的一个简单的'for'循环编写如下
用下面这种方法,可以offload到一个加速器(accelerator)中
基本并行内核的功能主要包含range、id和item类。Range类用于描述并行执行的迭代空间,id类用于在并行执行中索引内核的单个实例
上述的i可以换成item,这样的话,可以通过相关函数不仅得到原来的索引值i,还可以在这个循环内部得到range
Nd Range Kernels
基本并行内核是并行for循环的简单方法,但不允许在硬件级别进行性能优化。ND-Range内核是表达并行性的另一种方式,它通过提供对本地内存的访问和将执行映射到硬件上的计算单元来实现低级性能调优。整个迭代空间被分成称成很多工作组,工作组中的工作项被安排在硬件上的单个计算单元上
nd_range内核的功能通过nd_range和nd_item类公开。Nd_range类表示使用全局执行范围和每个工作组的本地执行范围的分组执行范围。Nd_item类表示内核函数的单个实例,并允许查询工作组范围和索引
比如上面这个例子,第一个range<1>(1024)表示全局执行范围是1024个工作单元,第二个range<1>(64)表示每个工作组的本地执行范围是64,也就是一个工作组有64个单元,也照应了图上的(4*4*4)区域
Buffer Model
缓冲区在跨设备和主机的SYCL应用程序中封装数据。访问器是访问缓冲区数据的机器
SYCL的代码开头内容:
SYCL程序是标准的c++。该程序在主机上调用,并将计算offload到加速器上。程序员使用SYCL的队列、缓冲区、设备和内核抽象来指示应该卸载哪些部分的计算和数据
SYCL程序的第一步,我们创建一个队列。我们通过将任务提交到队列,将计算量转移到设备上。程序员可以通过选择器选择CPU、GPU、FPGA等器件。这个程序在这里使用默认的q,这意味着SYCL运行时通过使用默认选择器来选择运行时可用的最有能力的设备。但下面是一个简单的SYCL程序
设备和主机可以共享物理内存,也可以拥有不同的内存。当内存不同时,offload计算需要在主机和设备之间复制数据。SYCL不要求程序员管理数据副本。通过创建缓冲区和访问器,SYCL确保数据对主机和设备可用,而无需程序员的任何努力。SYCL还允许程序员在需要实现最佳性能时显式控制数据移动
在SYCL程序中,我们定义了一个内核(就是device 运行的那部分代码)。对于像这样的简单程序,索引空间直接映射到数组的元素。内核被封装在一个c++ lambda函数中。lambda函数在索引空间中以坐标数组的形式传递一个点。对于这个简单的程序,索引空间坐标与数组索引相同。下面程序中的parallel_for将lambda应用到索引空间。索引空间在parallel_for的第一个参数中定义为从0到N-1的一维范围
对访问器(Accessor)的隐式依赖
访问器在SYCL图中创建对内核执行排序的数据依赖关系,如果两个内核使用相同的缓冲区,第二个内核需要等待第一个内核完成,以避免竞争条件,比如下面这个图,必须在kernel1运行完之后,缓冲区A才能空闲,然后才能继续运行kernel2
样例
%%writefile lab/buffer_sample.cpp
//==============================================================
// Copyright © Intel Corporation
//
// SPDX-License-Identifier: MIT
// =============================================================
#include <sycl/sycl.hpp>
constexpr int num=16;
using namespace sycl;
int main() {
auto R = range<1>{ num };
//Create Buffers A and B
buffer<int> A{ R }, B{ R };
//Create a device queue
queue Q;
//Submit Kernel 1
Q.submit([&](handler& h) {
//Accessor for buffer A
accessor out(A,h,write_only);
h.parallel_for(R, [=](auto idx) {
out[idx] = idx[0]; }); });
//Submit Kernel 2
Q.submit([&](handler& h) {
//This task will wait till the first queue is complete
accessor out(A,h,write_only);
h.parallel_for(R, [=](auto idx) {
out[idx] += idx[0]; }); });
//Submit Kernel 3
Q.submit([&](handler& h) {
//Accessor for Buffer B
accessor out(B,h,write_only);
h.parallel_for(R, [=](auto idx) {
out[idx] = idx[0]; }); });
//Submit task 4
Q.submit([&](handler& h) {
//This task will wait till kernel 2 and 3 are complete
accessor in (A,h,read_only);
accessor inout(B,h);
h.parallel_for(R, [=](auto idx) {
inout[idx] *= in[idx]; }); });
// And the following is back to device code
host_accessor result(B,read_only);
for (int i=0; i<num; ++i)
std::cout << result[i] << "\n";
return 0;
}
运行结果
结果解释:并行运行kerne11和kernel3,在缓冲区A和B中分别写入0--15这16个数,然后运行kernel2,让缓冲区A中的数翻二倍,最后运行kernel4,让缓冲区A中的0--30这16个数和缓冲区B中的0--15这16个数相乘,最后输出
Host Accessor
主机访问器是使用主机缓冲区访问目标的访问器。它是在命令组的作用域之外创建的,它们用于通过构造主机访问器对象将数据同步回主机。缓冲区销毁是将数据同步回主机的另一种方法
缓冲区获取存储在vector中的数据的所有权。创建主机访问器是一个阻塞调用,只有在所有队列中修改同一缓冲区的SYCL内核完成执行并且主机可以通过该主机访问器访问数据之后才会返回
运行结果
下面介绍,主机和设备数据同步的另一种方法:缓冲区销毁
Buffer Destruction
在下面的示例中,缓冲区创建发生在单独的函数作用域中。当执行超出此函数作用域时,调用缓冲区析构函数,从而放弃数据的所有权并将数据复制回主机内存
运行结果
Custom Device Selector
自定义设备选择器使用您自己的逻辑(评分机制)自定义设备选择器
特定供应商名称的自定义设备选择器
具有GPU和特定设备名称的自定义设备选择器
基于设备的优先级的自定义设备选择器
下面是基于上种情况的案例
运行结果
Lab Exercise: Vector Add
下面是官方文档中向量相加这个练习
下面是我已经补全的代码
#include <sycl/sycl.hpp>
using namespace sycl;
int main() {
const int N = 256;
//# Initialize a vector and print values
std::vector<int> vector1(N, 10);
std::cout<<"\nInput Vector1: ";
for (int i = 0; i < N; i++) std::cout << vector1[i] << " ";
//# STEP 1 : Create second vector, initialize to 20 and print values
//# YOUR CODE GOES HERE
std::vector<int> vector2(N, 20);
std::cout<<"\nInput Vector2: ";
for (int i = 0; i < N; i++) std::cout << vector2[i] << " ";
//# Create Buffer
buffer vector1_buffer(vector1);
//# STEP 2 : Create buffer for second vector
//# YOUR CODE GOES HERE
buffer vector2_buffer(vector2);
//# Submit task to add vector
queue q;
q.submit([&](handler &h) {
//# Create accessor for vector1_buffer
accessor vector1_accessor (vector1_buffer,h);
//# STEP 3 - add second accessor for second buffer
//# YOUR CODE GOES HERE
accessor vector2_accessor (vector2_buffer,h);
h.parallel_for(range<1>(N), [=](id<1> index) {
//# STEP 4 : Modify the code below to add the second vector to first one
vector1_accessor[index] += vector2_accessor[index];
});
});
//# Create a host accessor to copy data from device to host
host_accessor h_a(vector1_buffer,read_only);
//# Print Output values
std::cout<<"\nOutput Values: ";
for (int i = 0; i < N; i++) std::cout<< vector1[i] << " ";
std::cout<<"\n";
return 0;
}
运行结果