英特尔oneAPI——异构计算学习总结
oneAPI编程模型
oneAPI编程模型提供了一个全面、统一的开发人员工具组合,可用于各种硬件设备,其中包括跨多个工作负载领域的一系列性能库。这些库包括面向各目标架构而定制化代码的函数,因此相同的函数调用可为各种支持的架构提供优化的性能。**DPC++**基于行业标准和开放规范,旨在鼓励生态系统的协作和创新。
多架构编程面临的挑战
在以数据为中心的环境中,专用工作负载的数量不断增长。专用负载通常因为没有通用的编程语言或API而需要使用不同的语言和库进行编程,这就需要维护各自独立的代码库。
由于跨平台的工具支持不一致,因此开发人员必须学习和使用一整套不同的工具。单独投入精力给每种硬件平台开发软件。
oneAPI则可以利用一种统一的编程模型以及支持并行性的库,支持包括CPU、GPU、FPGA等硬件等同于原生高级语言的开发性能,并且可以与现有的HPC编程模型交互。
SYCL
SYCL支持C++数据并行编程,SYCL和OpenCL一样都是由Khronos Group管理的,SYCL是建立在OpenCL之上的跨平台抽象层,支持用C++用单源语言方式编写用于异构处理器的与设备无关的代码。
DPC++
DPC++(Data Parallel C++)是一种单源语言,可以将主机代码和异构加速器内核写在同一个文件当中,在主机中调用DPC++程序,计算由加速器执行。DPC++代码简洁且效率高,并且是开源的。现有的CUDA应用、Fortran应用、OpenCL应用都可以用不同方式很方便地迁移到DPC++当中。
下图显示了原来使用不同架构的HPC开发人员的一些推荐的转换方法。
编译和运行DPC++程序
编译和运行DPC++程序主要包括三步:
- 初始化环境变量
- 编译DPC++源代码
- 运行程序
例如本地运行,在本地系统上安装英特尔基础工具套件,使用以下命令编译和运行DPC++程序。
source /opt/intel/inteloneapi/setvars.sh
dpcpp simple.cpp -o simple
./simple
编程实例
实现矢量加法
以下实例描述了使用DPC++实现矢量加法的过程和源代码。
queue类
queue类用来提交给SYCL执行的命令组,是将作业提交到运算设备的一种机制,多个queue可以映射到同一个设备。
Parallel kernel
Parallel kernel允许代码并行执行,对于一个不具有相关性的循环数据操作,可以用Parallel kernel并行实现
在C++代码中的循环实现
for(int i=0; i < 1024; i++){
a[i] = b[i] + c[i];
});
在Parallel kernel中的并行实现
h.parallel_for(range<1>(1024), [=](id<1> i){
A[i] = B[i] + C[i];
});
通用的并行编程模板
h.parallel_for(range<1>(1024), [=](id<1> i){
// CODE THAT RUNS ON DEVICE
});
range用来生成一个迭代序列,1为步长,在循环体中,i表示索引。
Host Accessor
Host Accessor是使用主机缓冲区访问目标的访问器,它使访问的数据可以在主机上使用。通过构建Host Accessor可以将数据同步回主机,除此之外还可以通过销毁缓冲区将数据同步回主机。
buf是存储数据的缓冲区。
host_accessor b(buf,read_only);
除此之外还可以将buf设置为局部变量,当系统超出buf生存期,buf被销毁,数据也将转移到主机中。
矢量相加源代码
根据上面的知识,这里展示了利用DPC++实现矢量相加的代码。
//第一行在jupyter中指明了该cpp文件的保存位置
%%writefile lab/vector_add.cpp
#include <CL/sycl.hpp>
using namespace sycl;
int main() {
const int N = 256;
//# 初始化两个队列并打印
std::vector<int> vector1(N, 10);
std::cout<<"\nInput Vector1: ";
for (int i = 0; i < N; i++) std::cout << vector1[i] << " ";
std::vector<int> vector2(N, 20);
std::cout<<"\nInput Vector2: ";
for (int i = 0; i < N; i++) std::cout << vector2[i] << " ";
//# 创建缓存区
buffer vector1_buffer(vector1);
buffer vector2_buffer(vector2);
//# 提交矢量相加任务
queue q;
q.submit([&](handler &h) {
//# 为缓存区创建访问器
accessor vector1_accessor (vector1_buffer,h);
accessor vector2_accessor (vector2_buffer,h);
h.parallel_for(range<1>(N), [=](id<1> index) {
vector1_accessor[index] += vector2_accessor[index];
});
});
//# 创建主机访问器将设备中数据拷贝到主机当中
host_accessor h_a(vector1_buffer,read_only);
std::cout<<"\nOutput Values: ";
for (int i = 0; i < N; i++) std::cout<< vector1[i] << " ";
std::cout<<"\n";
return 0;
}
运行结果
统一共享内存 (Unified Shared Memory USM)
统一共享内存是一种基于指针的方法,是将CPU内存和GPU内存进行统一的虚拟化方法,对于C++来说,指针操作内存是很常规的方式,USM也可以最大限度的减少C++移植到DPC++的代价。
下图显示了非USM(左)和USM(右)的程序员开发视角。
类型 | 函数调用 | 说明 | 在主机上可访问 | 在设备上可访问 |
---|---|---|---|---|
设备 | malloc_device | 在设备上分配(显式) | 否 | 是 |
主机 | malloc_host | 在主机上分配(隐式) | 是 | 是 |
共享 | malloc_shared | 分配可以在主机和设备之间迁移(隐式) | 是 | 是 |
USM语法
- 初始化:
int *data = malloc_shared<int>(N, q);
int *data = static_cast<int *>(malloc_shared(N * sizeof(int), q));
- 释放
free(data,q);
使用共享内存之后,程序将自动在主机和运算设备之间隐式移动数据。
数据依赖
使用USM时,要注意数据之间的依赖关系以及事件之间的依赖关系,如果两个线程同时修改同一个内存区,将产生不可预测的结果。
我们可以使用不同的选项管理数据依赖关系:
- 内核任务中的 wait()
- 使用 depends_on 方法
- 使用 in_queue 队列属性
wait()
q.submit([&](handler &h) {
h.parallel_for(range<1>(N), [=](id<1> i) { data[i] += 2; });
}).wait(); // <--- wait() will make sure that task is complete before continuing
q.submit([&](handler &h) {
h.parallel_for(range<1>(N), [=](id<1> i) { data[i] += 3; });
});
depends_on
auto e = q.submit([&](handler &h) { // <--- e is event for kernel task
h.parallel_for(range<1>(N), [=](id<1> i) { data[i] += 2; });
});
q.submit([&](handler &h) {
h.depends_on(e); // <--- waits until event e is complete
h.parallel_for(range<1>(N), [=](id<1> i) { data[i] += 3; });
});
in_order queue property
queue q(property_list{property::queue::in_order()}); // <--- this will make sure all the task with q are executed sequentially
练习1:事件依赖
以下代码使用 USM,并有三个提交到设备的内核。每个内核修改相同的数据阵列。三个队列之间没有数据依赖关系
- 为每个队列提交添加 wait()
- 在第二个和第三个内核任务中实施 depends_on() 方法
- 使用 in_order 队列属性,而非常规队列:
queue q{property::queue::in_order()};
%%writefile lab/usm_data.cpp
#include <CL/sycl.hpp>
using namespace sycl;
static const int N = 256;
int main() {
queue q{property::queue::in_order()};//用队列限制执行顺序
std::cout << "Device : " << q.get_device().get_info<info::device::name>() << "\n";
int *data = static_cast<int *>(malloc_shared(N * sizeof(int), q));
for (int i = 0; i < N; i++) data[i] = 10;
q.parallel_for(range<1>(N), [=](id<1> i) { data[i] += 2; });
q.parallel_for(range<1>(N), [=](id<1> i) { data[i] += 3; });
q.parallel_for(range<1>(N), [=](id<1> i) { data[i] += 5; });
q.wait();//wait阻塞进程
for (int i = 0; i < N; i++) std::cout << data[i] << " ";
std::cout << "\n";
free(data, q);
return 0;
}
执行结果
练习2:事件依赖
以下代码使用 USM,并有三个提交到设备的内核。前两个内核修改了两个不同的内存对象,第三个内核对前两个内核具有依赖性。三个队列之间没有数据依赖关系
%%writefile lab/usm_data2.cpp
#include <CL/sycl.hpp>
using namespace sycl;
static const int N = 1024;
int main() {
queue q;
std::cout << "Device : " << q.get_device().get_info<info::device::name>() << "\n";//设备选择
int *data1 = malloc_shared<int>(N, q);
int *data2 = malloc_shared<int>(N, q);
for (int i = 0; i < N; i++) {
data1[i] = 10;
data2[i] = 10;
}
auto e1 = q.parallel_for(range<1>(N), [=](id<1> i) { data1[i] += 2; });
auto e2 = q.parallel_for(range<1>(N), [=](id<1> i) { data2[i] += 3; });//e1,e2指向两个事件内核
q.parallel_for(range<1>(N),{e1,e2}, [=](id<1> i) { data1[i] += data2[i]; }).wait();//depend on e1,e2
for (int i = 0; i < N; i++) std::cout << data1[i] << " ";
std::cout << "\n";
free(data1, q);
free(data2, q);
return 0;
}
运行结果
UMS实验
在主机中初始化两个vector,初始数据为25和49,在设备中初始化两个vector,将主机中的数据拷贝到设备当中,在设备当中并行计算原始数据的根号值,然后将data1_device和data2_device的数值相加,最后将数据拷贝回主机当中,检验最后相加的和是否是12,程序结束前将内存释放。
%%writefile lab/usm_lab.cpp
#include <CL/sycl.hpp>
#include <cmath>
using namespace sycl;
static const int N = 1024;
int main() {
queue q;
std::cout << "Device : " << q.get_device().get_info<info::device::name>() << "\n";
//intialize 2 arrays on host
int *data1 = static_cast<int *>(malloc(N * sizeof(int)));
int *data2 = static_cast<int *>(malloc(N * sizeof(int)));
for (int i = 0; i < N; i++) {
data1[i] = 25;
data2[i] = 49;
}
//# STEP 1 : Create USM device allocation for data1 and data2
int *data1_device = static_cast<int *>(malloc_device(N * sizeof(int),q));
int *data2_device = static_cast<int *>(malloc_device(N * sizeof(int),q));
//# STEP 2 : Copy data1 and data2 to USM device allocation
q.memcpy(data1_device, data1, sizeof(int) * N).wait();
q.memcpy(data2_device, data2, sizeof(int) * N).wait();
//# STEP 3 : Write kernel code to update data1 on device with sqrt of value
auto e1 = q.parallel_for(range<1>(N), [=](id<1> i) { data1_device[i] = std::sqrt(25); });
auto e2 = q.parallel_for(range<1>(N), [=](id<1> i) { data2_device[i] = std::sqrt(49); });
//# STEP 5 : Write kernel code to add data2 on device to data1
q.parallel_for(range<1>(N),{e1,e2}, [=](id<1> i) { data1_device[i] += data2_device[i]; }).wait();
//# STEP 6 : Copy data1 on device to host
q.memcpy(data1, data1_device, sizeof(int) * N).wait();
q.memcpy(data2, data2_device, sizeof(int) * N).wait();
//# verify results
int fail = 0;
for (int i = 0; i < N; i++) if(data1[i] != 12) {fail = 1; break;}
if(fail == 1) std::cout << " FAIL"; else std::cout << " PASS";
std::cout << "\n";
//# STEP 7 : Free USM device allocations
free(data1_device, q);
free(data1);
free(data2_device, q);
free(data2);
//# STEP 8 : Add event based kernel dependency for the Steps 2 - 6
return 0;
}
运行结果