ysh329 / OpenCL-101

Learn OpenCL step by step.
131 stars 29 forks source link

【竞品调研】MACE、MNN的OpenCL AutoTune 策略 #36

Open ysh329 opened 3 years ago

ysh329 commented 3 years ago

移动端OpenCL AutoTune 策略调研:MACE、MNN

OpenCL Tuner

Tuner很早就有实现,比方AMD的clBLAS,2016年在GTC上做报告的CLTune,其相应也有BLAS库,即CLBLAST,性能比clBLAS要好不少。CLTune设计了全面且通用的Tune框架,且支持CUDA,不局限于某一种kernel,且在tune策略上提供了除全局和随机搜索以外的启发搜索策略,如模拟退火、粒子群搜索,在使用上,用户可以集成到自己项目中以离线方式或者在线方式使用。

型号 SoC 架构 制程 核心数 每秒操作数(GFlops) 带宽(GB/s) 时钟频率 L2缓存大小 发布年份
Mali-450 Rockchip RK3328 Utgard 40/28nm 1~8 Cores 14.6 GFlops ? 650 MHz 512KB 2012
Mali-T860 Helio P10 MidgardGen4 28nm 1~16 Cores 23.8 GFlops ? 700 Mhz 256~2048KB 2015Q4
Mali-G72 Kirin970 BifrostGen2 16/12/10nm 1~32 Cores 30.5 GFlops ? 572~800 MHz 128~2048KB 2017Q2
Mali-G77 Kirin980 ValhallGen1 7nm 7~16 Cores ?GFlops ? 850 MHz 512~4096KB 2019Q2
Mali-G78 Kirin9000 ValhallGen2 5nm 7~24 Cores ?GFlops ? 850 MHz 512~2048KB 2020Q2

表: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。

型号 SoC 架构 制程 ALU个数 每秒操作数FP32 (GFlops) 带宽(GB/s) 时钟频率 片上内存大小 发布年份
Adreno430 Snapdragon810 Unified shader model 20nm 256 256/307/332 GFlops 25.6 GB/s 500/600/650 MHz 1536KB 2015
Adreno506 Snapdragon625 Unified shader model + Unified memory 14nm 96 115/124 GFlops 7.4 GB/s 600/650 MHz 128+8KB 2016
Adreno540 Snapdragon835 Unified shader model + Unified memory 10nm 384 545/567 GFlops 29.8 GB/s 710/739 MHz 1024KB 2016
Adreno640 Snapdragon855/855+ Unified shader model + Unified memory 7nm 784 898/1036 GFlops 34 GB/s 585/675 MHz 1024KB 2018

表: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推理框架的调优策略和思考。


ysh329 commented 3 years ago

MACE

MACE有TuningOrRun3DKernelTuningOrRun2DKernel两个方法分别针对2D和3D的global work size,其对应是两种params_generator,其位置在:core/runtime/opencl/opencl_helper.cc

1. 调优思路

移动端调优的特点是:

  1. 端侧GPU的计算性能相比PC端要弱;
  2. 端侧GPU的OpenCL编译耗时也要更长。

考虑到上述两点,Mace采用了只调优Work Size的思路。此外,其调优过程是离线进行的,即需要将手机插到电脑上,需要等待数十秒到1分钟左右,具体时间与手机的GPU性能有直接关系,调优结束后,会将编译好的OpenCL Program二进制文件+模型文件+调优参数,打包为一个文件,该文件的命名以模型名+SoC名来命名。

2. 默认LWS

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:

  1. 设置y方向的local 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方向;
  2. 设置z方向的local size:min( min(gws[2], base), kwg_size / lws[1] )
    1. 先说第一个内部的比较:base即是结合了global mem cache大小的限制,与同方向的gws取最小,确保当前工作组该方向不超出总线程数量的同时,也确保了当前work group所有work item所用的global mem cache>4096个float元素;
    2. 第二个min是与kernel_max_work_group比较,该值来自当前kernel对寄存器资源需求而确定,不能超出其上界;
  3. 设置x方向的local size:这里依旧是min比较,第一个base依旧是考虑到glboal mem cache的限制,第二个kwg_size / lws_size则是将当前硬件给该kernel的local size分配完。取二者最小的目的,同样是出于max_kernel_work_size不能超过local x/y/z三方向乘积考虑。

2. TuningOrRun3DKernel的params_generator

下面再来看一下其调优(Tune)的候选参数在3D Kernel上的生成器策略,该TuningOrRun3DKernel方法被几乎所有Image2D实现的kernel所采用,如conv2d/depthwise等等,代码如下:

其实不难发现,是罗列出潜在的local size固定组合,该固定组合是基于global size而得到,分为两种:

  1. 原有global size+1个固定数值的组合:如{gws[0], gws[1], gws[2]}、{gws[0], gws[1], 8}、{4, gws[1], gws[2]}等等。
  2. 原有global size+对global size除以一个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几个规律:

  1. 固定数值(1/2/4/8)仅在x和z方向出现:确保是2的幂次,应该是workgroup的二次幂对齐;
  2. y方向始终为gws在y方向的值:确保该方向在workgroup的计算资源,该方向可能是连续,或者处理的任务数>1。该方向gws值,一般为w_blk,即tensor_w。

3. TuningOrRun2DKernel的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个数。

4. WaitForQueueExecution

TuningOrRun3DKernelTuningOrRun2DKernel中都有用到一个名为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代码不难看出,针对卡顿问题的策略:

  1. 通过每个op,对已有的OpenCL Command counter计数;
  2. 当counter数大于预设的数字(用户自定义)时,对上一个op执行event.wait();clWaitForEvent的作用:clWaitForEvents等待的是gpu命令队列中的命令的执行状态成为已完成,即CL_COMPLETE,表示该命令已完成,此外由于OpenCL也支持OpenGL扩展,如果是gl的事件那么也能反映gl同步对象的状态。clWaitForEventclFinish二者可以阻塞直到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卡顿问题时,不要用该值。

image

image

ysh329 commented 3 years ago

MNN

关键词检索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也有些许不同。但结构基本类似

ysh329 commented 3 years ago

placeholder

ysh329 commented 3 years ago

placeholder