毕昇编译器异构算子开发基本思想
基于毕昇编译器的异构编程基本思想,如果理解CUDA编程的话,可以类比理解。
毕昇C++异构开发基本思想
队列
首先定义任务队列,任务队列用来管理device上的可执行任务。
1 |
|
queue
来自命名空间sycl
,需要引入头文件#include <sycl/sycl.hpp>
。
Host数据
想要将host上的数据搬移到device,一个核心思想就是找到host端的数据指针。如果待迁移算子中的数据封装度较高,大体上可以分为两种情况:
- 指针传递式的封装。
- 数据结构式的封装。
如果只是指针传递式的封装,即封装过程仅仅是将数据指针一层一层传递过来,则是比较简单的情况,只需取得该指针即可。
如果是数据结构式的封装,即封装过程中使得子类无法读取到数据的指针,则是较为复杂的情况,迁移的工作量可能会比较大。但依然没有脱离最核心的思想——找到host端的数据指针。
这里举一个简单的例子,在host端定义两个数组。
1 |
|
ptrDataA
与ptrDataB
是host端的数据指针。
Device数据
为了将host端的数据搬移到device端,需要先在device端申请内存,申请的大小根据实际情况决定。这里需要将两个矩阵都搬移至device上,故申请如下大小的内容。
1 |
|
devA
和devB
分别为device端的数据指针,这两片空间处于Global
Memory中,但此时仅仅是申请了内存,这两片设备内存中并不存在任何数据。接下来就需要将host端的数据正式拷贝到device端。
1 |
|
memcpy()
接口定义如下。
1 |
|
Dst
为目标地址。Src
为源地址。Size
为待搬移数据的大小。
提交任务
截止目前,数据已经在device端准备完毕,接下来就可以进行任务的提交。任务以核函数的形式,作为参数传递给launch()
接口。
首先来看一下launch()
的定义。
1 |
|
接口定义虽然比较复杂,还涉及到一些宏,但我们只需要关注两个参数:
NumWorkGroups
为work-group的数量。KernelFunc
为核函数。
核函数以lambda表达式的方式进行定义。
1 |
|
接下来进行任务的提交。
1 |
|
该行代码指定了N
个work-group
,并传入一个核函数KernelFunc
作为device端执行的实际操作。
当然也可以直接将任务提交与核函数定义的代码合并,省去单独定义KernelFunc
的步骤。
1 |
|
C++中的lambda表达式定义方式如下。
1
auto func = [capture] (params) mutable throw() -> return-type { func_body };
[capture]
:用来捕获一定范围的变量。
[ ]
不捕获任何变量;[&]
引用捕获,捕获外部作用域所有变量,在函数体内当作引用使用;[=]
值捕获,捕获外部作用域所有变量,在函数体内创建一个拷贝使用;[=, &a]
值捕获外部作用域所有变量,按引用捕获a变量;[a]
值捕获外部作用域所有变量,按引用捕获a变量;[this]
捕获当前类中的this指针。
(params)
:参数列表。
mutable
:当使用值捕获时,加上mutable
关键字就可以对捕获到的值进行修改。
throw()
:用于函数体抛出异常
return-type
:用来显式指定返回类型,当不需要返回值或返回类型明确的情况下,可以将->
与return-type
一同省略
{ func_body }
:函数体。
这里以矩阵的update
操作为例,该操作给定两个矩阵A
和B
,以及两个参数left
和top
,将矩阵A
从(left, top)
元素开始的与矩阵B
大小相同的子矩阵替换为矩阵B
。
1 |
|
我们来一行一行解析上面的代码。
首先看第一行__local int UBBuf[N]
,该语句利用空间制导符__local
在Unified
Buffer中申请了一片大小为N
的内存空间。
第二行size_t groupId = group.get_id()
利用get_id()
接口获取到了当前的work-group的id。
第三行dmi::memcpy_blocks(UBBuf, &devB[groupId * N], N * sizeof(int) / 32)
,利用命名空间dmi
下的memcpy_blocks()
接口进行连续数据的拷贝,将之前定义的Global
Memory中devB
的数据并行的拷贝至Unified
Buffer中的UBBuf
中。可以观察到拷贝的源地址是利用groupId
计算得来的,这一点后面会详细解释。
第四行dmi::memcpy_blocks(&devA[top * M + groupId * M + left], UBBuf, N * sizeof(int) / 32)
,同样是利用连续数据拷贝的接口,将Unified
Buffer中的UBBuf
中的数据,拷贝到Global
Memory中devA
的正确位置,从而实现update
操作。拷贝的目的地址同样是利用groupId
与参数left
和top
计算得来。
work-group的理解
以上面提到的矩阵update()
操作为例。
当groupId == 0
时,&devA[top * M + groupId * M + left]
计算的结果为&devA[top * M + left]
,即图中groupId=0
箭头所指的那一行数据。而groupId == 1
时,同理,计算结果为&devA[top * M + M + left]
,即比groupId == 0
时多向前指了一行数据,也即图中groupId=1
箭头所指的数据。
理解work-group最重要的一点就是,要意识到所有group都是并行的,是同时执行的。
取回数据
通过核函数使device执行完任务后,最后一步就是要将运算结果从device上取回host,也即从Global Memory中搬移回Host Memory中。
1 |
|
最后可以利用wait()
接口对device端的任务进行同步。
1 |
|
完整示例
1 |
|
毕昇编译器异构算子开发基本思想