Open ysh329 opened 3 years ago
MACE有TuningOrRun3DKernel
和TuningOrRun2DKernel
两个方法分别针对2D和3D的global work size,其对应是两种params_generator
,其位置在:core/runtime/opencl/opencl_helper.cc。
移动端调优的特点是:
考虑到上述两点,Mace采用了只调优Work Size的思路。此外,其调优过程是离线进行的,即需要将手机插到电脑上,需要等待数十秒到1分钟左右,具体时间与手机的GPU性能有直接关系,调优结束后,会将编译好的OpenCL Program二进制文件+模型文件+调优参数,打包为一个文件,该文件的命名以模型名+SoC名
来命名。
Mace也有默认对LocalWorkSize的设定,其代码如下:
std::vector<uint32_t> Default3DLocalWS(OpenCLRuntime *runtime,
const uint32_t *gws,
const uint32_t kwg_size) {
std::vector<uint32_t> lws(4, 0);
if (kwg_size == 0) {
lws[0] = lws[1] = lws[2] = 1;
} else {
uint64_t cache_size = runtime->device_global_mem_cache_size();
uint32_t base = std::max<uint32_t>(cache_size / kBaseGPUMemCacheSize, 1);
lws[1] = std::min<uint32_t>(gws[1], kwg_size);
lws[2] =
std::min<uint32_t>(std::min<uint32_t>(gws[2], base), kwg_size / lws[1]);
const uint32_t lws_size = lws[1] * lws[2];
lws[0] = std::max<uint32_t>(std::min<uint32_t>(base, kwg_size / lws_size),
1);
}
return lws;
}
首先获取global mem cache大小如在mali-G52上位256KB
,并用其除以kBaseGPUMemCacheSize
,后者是一个为16384的常数值仅用于计算local work size,base=max(256000 / 16384, 1)=15
,看来是将16384作为一个单位,在以G52的例子中,其Global mem cache有15个这样的单位即base
在后续有用到,我的理解是在一个work size内够15个该方向的work-item使用,而其中的16384字节为1个线程使用的量级,一个float为4字节,那么一个线程可以保证有4096个在cache里。但实际,并非是1个线程对应4096,而是当前一个work-group内的所有线程。因为work group还有另外两个方向。
硬件上的限制,kwg_size的全称是kernel_max_work_group_size,该参数是通过clGetKernelWorkGroupInfo
获取到的CL_KERNEL_WORK_GROUP_SIZE
,在官网文档上对其解释为:查询用于在设备给定的情况下,该内核能获取到的的最大工作组大小。OpenCL通过观察Kernel对所需寄存器资源的多少,来决定work group的大小应该是多少。即work size三个方向的连乘积不能超过CL_KERNEL_WORK_GROUP_SIZE。
下面就开始分别设定y、z、x方向的local work size:
lws[1] = min(gws[1], kwg_size)
,gws[1]
与lws[1]
都是y方向,而kwg_size为local(xyz)连乘积的上界,因而二者取最小。当gws[1] < kwg_size
时,gws[1]
才能被选中作为work group的y方向;min( min(gws[2], base), kwg_size / lws[1] )
:
>4096
个float元素;kwg_size / lws_size
则是将当前硬件给该kernel的local size分配完。取二者最小的目的,同样是出于max_kernel_work_size不能超过local x/y/z三方向乘积考虑。params_generator
下面再来看一下其调优(Tune)的候选参数在3D Kernel上的生成器策略,该TuningOrRun3DKernel
方法被几乎所有Image2D实现的kernel所采用,如conv2d/depthwise等等,代码如下:
其实不难发现,是罗列出潜在的local size固定组合,该固定组合是基于global size而得到,分为两种:
{gws[0], gws[1], gws[2]}、{gws[0], gws[1], 8}、{4, gws[1], gws[2]}
等等。{gws[0], gws[1], gws[2] / 8}、{4, gws[1], gws[2] / 4}
。 auto params_generator = [&]() -> std::vector<std::vector<uint32_t>> {
const uint32_t kwg_size =
static_cast<uint32_t>(runtime->GetKernelMaxWorkGroupSize(kernel));
std::vector<std::vector<uint32_t>> results;
std::vector<std::vector<uint32_t>> candidates = {
// TODO(heliangliang): tuning these magic numbers
{gws[0], gws[1], gws[2], 0},
{gws[0], gws[1], gws[2] / 8, 0},
{gws[0], gws[1], gws[2] / 4, 0},
{gws[0], gws[1], 8, 0},
{gws[0], gws[1], 4, 0},
{gws[0], gws[1], 1, 0},
{gws[0] / 4, gws[1], gws[2], 0},
{gws[0] / 4, gws[1], gws[2] / 8, 0},
{gws[0] / 4, gws[1], gws[2] / 4, 0},
{gws[0] / 4, gws[1], 8, 0},
{gws[0] / 4, gws[1], 4, 0},
{gws[0] / 4, gws[1], 1, 0},
{gws[0] / 8, gws[1], gws[2], 0},
{gws[0] / 8, gws[1], gws[2] / 8, 0},
{gws[0] / 8, gws[1], gws[2] / 4, 0},
{gws[0] / 8, gws[1], 8, 0},
{gws[0] / 8, gws[1], 4, 0},
{gws[0] / 8, gws[1], 1, 0},
{4, gws[1], gws[2], 0},
{4, gws[1], gws[2] / 8, 0},
{4, gws[1], gws[2] / 4, 0},
{4, gws[1], 8, 0},
{4, gws[1], 4, 0},
{4, gws[1], 1, 0},
{1, gws[1], gws[2], 0},
{1, gws[1], gws[2] / 8, 0},
{1, gws[1], gws[2] / 4, 0},
{1, gws[1], 8, 0},
{1, gws[1], 4, 0},
{1, gws[1], 1, 0},
};
// 连乘积不能超过kernel max work size
for (auto &ele : candidates) {
const uint32_t tmp = ele[0] * ele[1] * ele[2];
if (0 < tmp && tmp <= kwg_size) {
results.push_back(ele);
}
}
return results;
};
根据上面的组合,能看出其生成的local work size几个规律:
params_generator
该TuningOrRun2DKernel
方法主要用于buffer实现的kernel和少部分Image2D的kernel。
auto params_generator = [&]() -> std::vector<std::vector<uint32_t>> {
const uint32_t kwg_size =
static_cast<uint32_t>(runtime->GetKernelMaxWorkGroupSize(kernel));
std::vector<std::vector<uint32_t>> results;
std::vector<std::vector<uint32_t>> candidates = {
{kwg_size / 2, 2, 0}, {kwg_size / 4, 4, 0},
{kwg_size / 8, 8, 0}, {kwg_size / 16, 16, 0},
{kwg_size / 32, 32, 0}, {kwg_size / 64, 64, 0},
{kwg_size / 128, 128, 0}, {kwg_size / 256, 256, 0},
{kwg_size, 1, 0}, {1, kwg_size, 0}};
for (auto &ele : candidates) {
const uint32_t tmp = ele[0] * ele[1];
if (0 < tmp && tmp <= kwg_size) {
results.push_back(ele);
}
}
return results;
};
相比3D,2D的local size生成实现完全不基于global size,仅用了2维,根据10个候选,其中9个的x方向都是kernel max work group size的变换——被2的幂次除(2/4/8/16/32/64/128/256),y方向则为x方向被2除的幂次如组合{kwg_size / 2, 2}、{{kwg_size / 256, 256}
,目的是完全利用好硬件基于的work group size个数。
在TuningOrRun3DKernel
和TuningOrRun2DKernel
中都有用到一个名为WaitForQueueExecution
的方法,该方法位于enqueueNDRangeKernel结束后紧接着就执行。
根据代码注释,说明:对于ARM Mali GPU来说,当过多的命令添加进命令队列(command queue)时,用户界面可能会导致延迟。该函数就是限制添加进命令队列的命令个数不要超过设置的kQueueWndSize
。当命令队列中的命令个数大于等于kQueueWndSize
时,程序会等待GPU命令队列执行完成。
不过追踪runtime->tuner()->GetOpenclQueueWindowSize()
源码发现,该方法在tune模式下是不会启用,只有正常Run时才会使用。
/**
* For GPUs like Arm Mali, when too many commands are added in the command
* queue, the UI responsiveness may be poor. This function limits the number of
* comands in the command queue to no more than kQueueWndSize. when
* opencl_commands >= kQueueWndSize, it will wait for the completion of GPU
* command queue's execution.
*
* If kQueueWndSize <= 0, this function does nothing.
*/
inline void WaitForQueueExecution(OpenCLRuntime *runtime,
const cl::Event &event) {
static const unsigned int kQueueWndSize =
runtime->tuner()->GetOpenclQueueWindowSize();
static thread_local unsigned int opencl_commands = 0;
if (kQueueWndSize > 0) {
opencl_commands++;
if (opencl_commands >= kQueueWndSize) {
event.wait();
opencl_commands = 0;
}
}
}
constexpr const char *kOpenClWindowSize = "MACE_OPENCL_QUEUE_WINDOW_SIZE";
unsigned int GetOpenclQueueWindowSize() {
unsigned int window_size = 0;
if (!IsTuning()
&& param_table_.find(kOpenClWindowSize) != param_table_.end()) {
window_size = param_table_[kOpenClWindowSize][0];
}
return window_size;
}
对Map结构的param_table_
的key——kOpenClWindowSize
追踪该值是通过环境变量导入的, 后通过程序GetEnv获取到。也就是说,该值是用户自定义。
根据上面GetOpenclQueueWindowSize
代码不难看出,针对卡顿问题的策略:
event.wait();
,clWaitForEvent
的作用:clWaitForEvents等待的是gpu命令队列中的命令的执行状态成为已完成,即CL_COMPLETE,表示该命令已完成,此外由于OpenCL也支持OpenGL扩展,如果是gl的事件那么也能反映gl同步对象的状态。clWaitForEvent
和clFinish
二者可以阻塞直到kernel执行完成,但后者会也是一个同步点,会更慢。用opencl后端运行模型,因为gpu资源的占用,在手机上显示的UI界面会变得慢,甚至是卡顿。这点在mace的文档有提到,mace也给出了解决方案:
即设置一个limit_opencl_kernel_time
值,如1。若没有被解决,可以考虑切换到CPU或者DSP解决。
对如Arm mali GPU来说,有时候设置到一个较小的时间间隔并不能解决问题。这时可以尝试对opencl_queue_window_size设置为如16。这个参数表示GPU的命令队列(command queue)最多能包含的命令个数,你可以调整该值以达到性能与UI响应的平衡,当没有UI卡顿问题时,不要用该值。
关键词检索TUNE(https://github.com/alibaba/MNN/search?q=tune
),找到MNN_OPENCL_LWS_TUNE
的CMake宏定义开关。且默认打开状态。
conv2d1x1最为常用,这里以它作为例子conv2d1x1LocalWSOpt
// https://github.com/alibaba/MNN/blob/10a7c0a00a19d5d3526eebf3ba3ab107a02b95ec/source/backend/opencl/execution/ConvExecution.cpp#L24-L74
std::vector<uint32_t> ConvExecution::conv2d1x1LocalWSOpt(std::vector<uint32_t> &gws, const uint32_t maxWorkGroupSize) {
#ifdef MNN_OPENCL_LWS_TUNE
MNN_ASSERT(gws.size() == 2);
auto maxWorkItemSizes = mOpenCLBackend->getOpenCLRuntime()->getMaxWorkItemSizes();
MNN_ASSERT(maxWorkItemSizes.size() >= 2);
auto& tunedLws = mOpenCLBackend->getOpenCLRuntime()->tunedLwsMap();
std::pair<std::string, std::vector<uint32_t>> info = std::make_pair("conv2d1x1LocalWSOpt", gws);
if (tunedLws.find(info) != tunedLws.end()) {
//printf("conv2d1x1LocalWSOpt Found! gws:%d %d lws:%d %d\n", gws[0], gws[1], tunedLws[info][0], tunedLws[info][1]);
return tunedLws[info];
}
std::vector<uint32_t> lws(3, 1);
std::vector<uint32_t> lws_prefer(4, 1);
int min_cost = INT_MAX;
while(lws[1] <= gws[1]*2 || lws[1] <= 4) {
lws[0] = 1;
while(lws[0] <= gws[0]*2 || lws[0] <= 4) {
if(lws[0] <= maxWorkItemSizes[0] && lws[1] <= maxWorkItemSizes[1] && lws[0]*lws[1] <= maxWorkGroupSize) {
cl::Event event;
std::vector<uint32_t> internalGlobalWS(2, 1);
for (size_t i = 0; i < gws.size(); ++i) {
internalGlobalWS[i] = ROUND_UP(gws[i], std::max((uint32_t)1, lws[i]));
}
cl_int error = mOpenCLBackend->getOpenCLRuntime()->commandQueue().enqueueNDRangeKernel(
mKernel, cl::NullRange,
cl::NDRange(internalGlobalWS[0], internalGlobalWS[1]),
cl::NDRange(lws[0], lws[1]),
nullptr, &event);
MNN_CHECK_CL_SUCCESS(error);
int cost_time = (int)mOpenCLBackend->getOpenCLRuntime()->getCostTime(&event);
if(cost_time < min_cost) {
min_cost = cost_time;
lws_prefer[0] = lws[0];
lws_prefer[1] = lws[1];
}
}
lws[0] *= 2;
}
lws[1] *= 2;
}
if (tunedLws.find(info) == tunedLws.end()) {
//printf("conv2d1x1LocalWSOpt %d Insert! gws:%d %d, lws:%d %d\n", (int)tunedLws.size(), gws[0], gws[1], lws_prefer[0], lws_prefer[1]);
tunedLws.insert(std::make_pair(info, lws_prefer));
}
return lws_prefer;
#else
auto maxWorkItemSizes = mOpenCLBackend->getOpenCLRuntime()->getMaxWorkItemSizes();
uint32_t deviceComputeUnits = mOpenCLBackend->getOpenCLRuntime()->deviceComputeUnits();
std::vector<uint32_t> lws(4, 1);
int coreNum = deviceComputeUnits * 2;
for (int i = 0, totalSizeNow = 1; i < gws.size(); ++i) {
int remain = gws[i] % coreNum, groupSize = gws[i] / coreNum;
if (remain == 0) {
lws[i] = groupSize;
} else {
while(groupSize) {
int remain = gws[i] % groupSize;
if (remain == 0 && (i > 0 || groupSize <= maxWorkGroupSize)) {
lws[i] = groupSize;
break;
}
--groupSize;
}
}
int limit = std::min<uint32_t>(maxWorkGroupSize / totalSizeNow, maxWorkItemSizes[i]);
lws[i] = std::max<uint32_t>(std::min<uint32_t>(lws[i], limit), 1);
totalSizeNow *= lws[i];
}
return lws;
#endif
}
其它带有tune的op,在tune部分与上述conv2d1x1的代码结构基本一样,但个别不同,如DepthwiseConvExecution中,tune的两个while循环的条件不同:
while(lws[1] <= gws[1]) {
lws[0] = 1;
while(lws[0] <= gws[0]) {
因为MNN_OPENCL_LWS_TUNE是一个宏,对于else情况,其会走默认的情况,这里conv2d1x1与depthwise也有些许不同。但结构基本类似
placeholder
placeholder
移动端OpenCL AutoTune 策略调研:MACE、MNN
OpenCL Tuner
Tuner很早就有实现,比方AMD的clBLAS,2016年在GTC上做报告的CLTune,其相应也有BLAS库,即CLBLAST,性能比clBLAS要好不少。CLTune设计了全面且通用的Tune框架,且支持CUDA,不局限于某一种kernel,且在tune策略上提供了除全局和随机搜索以外的启发搜索策略,如模拟退火、粒子群搜索,在使用上,用户可以集成到自己项目中以离线方式或者在线方式使用。
表:Arm Mali GPU数据来自维基百科的Mali(GPU)
但随着近年来移动端GPU越发强劲,上表是移动端GPU大体的情况数据来自维基百科的Mali(GPU)和Adreno(GPU),可以看到表格中,Arm Mali GPU支持的最大核心数近年来都在上升,尤其是FP32精度下的每秒乘加数2012年在Mali450上,是14.6GFops,而到了2017年Kirin970的Mali-G72已达到30.5GFlops,而FP16理论上应该在60GFlops左右,先前在Mali-T860上测试MobileNetv1上性能可以做到将近10FPS。
表:SnapDragon Adreno GPU数据来自维基百科的Adreno(GPU)
根据Arm Mali GPU的数据,再结合Adreno GPU,可以发现在片上缓存或L2缓存的上下限、ALU个数的属性等都在逐年增加,而时钟频率基本维持在在600~800MHz,因为功耗和频率成正比,而时钟频率和运算能力并没有直接关系(运算速度还要看流水线的各方面的性能指标如缓存、指令集位数等等)。
在上面的Adreno GPU表中,在架构的术语上做一些解释:
另外,Adreno430(骁龙810)比Adreno506(骁龙625)虽然前者是4xx系列且是20nm工艺后者14nm,但是骁龙8系列,毕竟瘦死的骆驼比马大,好歹骁龙810是高端的8系列,其ALU个数是256个比骁龙625(的GPU)96个多了1倍有余,且能从每秒操作数和带宽数也能看出。
而且在移动端做深度学习,尤其是卷积以及矩阵乘法的性能需求越来越明显,即使是适用于并行计算的移动GPU,性能的需求也是永无止境的。为了榨干最后的性能,大家都会选择kernel调优,一般是暴力搜索的同时融合人工经验减少搜索空间。
在这方面较早的是前面已经说了,有clBLAS、clBlast,到了端侧AI模型推理框架,较早的有ARM Compute Library、MACE等等,下面将介绍一下在移动端AI推理框架的调优策略和思考。