模型推理应用开发指导

概述

本章节介绍如何在地平线平台进行模型推理应用开发,以及需要您注意的相关事项。

注意

在开始开发应用前,请确保您已经根据 环境部署 章节的内容完成了开发环境准备。

最简易的开发过程包括工程创建、工程实现、工程编译与运行三个阶段。 考虑到实际业务场景开发的较复杂需求,对于常用的多模型控制概念和应用调优建议也都提供了一些说明。

工程创建

地平线推荐使用cmake进行应用工程管理,前文介绍的环境部署部分也已经完成了cmake安装。 在阅读本节内容前,我们希望您已经了解cmake的使用。

地平线开发库提供了相关的工程依赖。具体依赖信息如下:

  • 地平线部署依赖库libdnn.so,libucp.so等,路径:${OE_DIR}/samples/ucp_tutorial/deps_aarch64/ucp/
  • C编译器 aarch64-none-linux-gnu-gcc。
  • C++编译器 aarch64-none-linux-gnu-g++。
注解

上方$ {OE_DIR} 指地平线提供的OE包路径。

创建一个工程,您需要编写 CMakeLists.txt 文件,CMakeLists.txt 文件中定义了一些编译选项,以及依赖库、头文件的路径。参考如下:

cmake_minimum_required(VERSION 3.0)

project(your_project_name)

# libdnn.so depends on system software dynamic link library, use -Wl,-unresolved-symbols=ignore-in-shared-libs to shield during compilation
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11 -Wl,-unresolved-symbols=ignore-in-shared-libs")

set(CMAKE_CXX_FLAGS_DEBUG " -Wall -Werror -g -O0 ")
set(CMAKE_C_FLAGS_DEBUG " -Wall -Werror -g -O0 ")
set(CMAKE_CXX_FLAGS_RELEASE " -Wall -Werror -O3 ")
set(CMAKE_C_FLAGS_RELEASE " -Wall -Werror -O3 ")
if (NOT CMAKE_BUILD_TYPE)
    set(CMAKE_BUILD_TYPE Release)
endif ()

message(STATUS "Build type: ${CMAKE_BUILD_TYPE}")
# define dnn lib path
set(DNN_PATH "${OE_DIR}/samples/ucp_tutorial/deps_aarch64/ucp/")

set(DNN_LIB_PATH ${DNN_PATH}/lib)

include_directories(${DNN_PATH}/include)
link_directories(${DNN_LIB_PATH})

add_executable(user_app main.cc)
target_link_libraries(user_app
                      dnn
                      ucp
                      pthread
                      rt
                      dl)

注意在以上示例中,我们没有指定编译器位置,会在工程编译阶段补充编译器指定,请参考 工程编译与运行 小节部分的介绍。

工程实现

工程实现部分,我们主要为您介绍如何将hbm模型在地平线平台运行起来。 最简单的步骤应该包括模型加载、准备输入数据、准备输出内存、推理和结果解析,以下是一份简单的模型部署参考代码:

#include <iostream>
#include "hobot/dnn/hb_dnn.h"
#include "hobot/hb_ucp.h"
#include "hobot/hb_ucp_sys.h"

int main(int argc, char **argv) {
  // Step 1: Load the model
  hbDNNPackedHandle_t packed_dnn_handle;
  const char* model_file_name= "./mobilenetv1/mobilenetv1_224x224_nv12.hbm";
  hbDNNInitializeFromFiles(&packed_dnn_handle, &model_file_name, 1);

  // Step 2: Get model names
  const char **model_name_list;
  int model_count = 0;
  hbDNNGetModelNameList(&model_name_list, &model_count, packed_dnn_handle);

  // Step 3: Get dnn_handle
  hbDNNHandle_t dnn_handle;
  hbDNNGetModelHandle(&dnn_handle, packed_dnn_handle, model_name_list[0]);

  // Step 4: Prepare input data
  int input_count = 0;
  hbDNNGetInputCount(&input_count, dnn_handle);
  std::vector<hbDNNTensor> input(input_count);
  for (int i = 0; i < input_count; i++) {
    hbDNNGetInputTensorProperties(&input[i].properties, dnn_handle, i);
    auto &mem = input[i].sysMem;
    /*
      1. For dynamic input, you need to set the corresponding dynamic parameters in input[i].properties.
      2. Call hbUCPMalloc/hbUCPMallocCached to apply for the corresponding memory size based on the model input.
         such as hbUCPMallocCached(&mem, size, 0);
      3. Determine whether the input needs quantization or padding based on properties, then fill the input data into mem.
      4. If the memory is a cacheable, you must actively perform a flush operation after writing.
         such as hbUCPMemFlush(&mem, HB_SYS_MEM_CACHE_CLEAN); 
    */
  }

  // Step 5: Prepare storage space for the output data of the model
  int output_count = 0;
  hbDNNGetOutputCount(&output_count, dnn_handle);
  std::vector<hbDNNTensor> output(output_count);
  for (int i = 0; i < output_count; i++) {
    hbDNNTensorProperties &output_properties = output[i].properties;
    hbDNNGetOutputTensorProperties(&output_properties, dnn_handle, i);
    int out_aligned_size = output_properties.alignedByteSize;
    hbUCPSysMem &mem = output[i].sysMem;
    hbUCPMallocCached(&mem, out_aligned_size, 0);
  }

  // Step 6: Create the asynchronous inference task
  hbUCPTaskHandle_t task_handle{nullptr};
  hbDNNInferV2(&task_handle,
               output.data(),
               input.data(),
               dnn_handle);

  // Step 7: Submit the task
  hbUCPSchedParam infer_sched_param;
  HB_UCP_INITIALIZE_SCHED_PARAM(&infer_sched_param);
  hbUCPSubmitTask(task_handle, &infer_sched_param);

  // Step 8: Wait for the task to end
  hbUCPWaitTaskDone(task_handle, 0);

  // Step 9: Parse model output, here we take the classification model as an example to get the top1 result
  for (int i = 0; i < output_count; i++) {
    // For output memory that applies for the cacheable attribute, it needs to be actively flushed before reading.
    hbUCPMemFlush(&(output[i].sysMem), HB_SYS_MEM_CACHE_INVALIDATE);

    /*
       1. Determine whether the output data has padding, whether inverse quantization is required, and the quantization parameter information required for inverse quantization, etc.
       2. parsing process.
    */
  }

  // Release the task
  hbUCPReleaseTask(task_handle);

  // Release the memory
  for (int i = 0; i < input_count; i++) {
    hbUCPFree(&(input[i].sysMem));
  }
  for (int i = 0; i < output_count; i++) {
    hbUCPFree(&(output[i].sysMem));
  }

  // Release the model
  hbDNNRelease(packed_dnn_handle);

  return 0;
}

示例代码中,为了缩减篇幅,模型部分处理以注释形式描述,更详细的细节后续文档有说明,比如 动态输入说明请参考 动态输入说明, 内存对齐规则请参考 对齐规则 章节。 更加全面的工程实现指导请阅读 模型推理API手册模型推理基础示例包使用说明

工程编译和运行

结合 工程创建 一节中的cmake工程配置,参考如下编译脚本:

# Define gcc path for ARM
LINARO_GCC_ROOT=/usr
DIR=$(cd "$(dirname "$0")";pwd)
export CC=${LINARO_GCC_ROOT}/bin/aarch64-none-linux-gnu-gcc
export CXX=${LINARO_GCC_ROOT}/bin/aarch64-none-linux-gnu-g++

rm -rf build_arm
mkdir build_arm
cd build_arm

cmake ${DIR}

make -j8

根据 环境部署 部分的指引,您的开发机中应该已经安装有相应编译器,将上述脚本中的编译器配置指定为您的安装项目即可。

arm程序拷贝到地平线开发板上可运行,注意程序依赖的文件也需要一同拷贝到开发板,并在启动脚本中配置依赖。 例如我们的示例程序依赖库有: libhbucp.so、libdnn.so 以及其他bsp库,这些依赖库都可在OE包的 ucp_tutorial/deps_aarch64/ 路径下找到,需要上传到板子的运行环境中。 我们建议您在板端的 /userdata 路径下新建 lib 路径并将库传送至该目录下,则在板端运行程序前,需指定的依赖库路径信息如下:

export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/userdata/lib

多模型控制策略

多模型场景中,每个模型都需要使用有限的计算资源完成推理,不可避免地会出现计算资源的争夺情况。 为了便于您控制多模型的执行,地平线提供了模型优先级的控制策略供您使用。

模型优先级控制

注意

请注意,此功能仅支持在开发板端实现,x86模拟器不支持此功能。

J6计算平台BPU计算单元硬件本身没有任务抢占功能,对于每一个推理任务,一旦它进到BPU模型计算之后,在该任务执行完成之前都会一直占用BPU,其他任务只能排队等待。 此时很容易出现BPU计算资源被一个大模型推理任务所独占,进而影响其他高优先级模型的推理任务执行。 针对这种问题,Runtime SDK基于模型的优先级通过软件的方式实现了BPU资源抢占的功能。

其中有以下点需要被关注:

  • 编译后的数据指令模型在BPU上进行推理计算时,它将表现为1个或者多个function-call的调用,其中function-call是BPU的执行粒度,多个function-call调用任务将在BPU的硬件队列上按序进行调度,当一个模型所有的function-call都执行完成,那么一个模型推理任务也就执行完成了。
  • 基于上述描述,BPU模型任务抢占粒度设计为function-call更为简单,即BPU执行完一个function-call之后,暂时挂起当前模型,然后切入执行另外一个模型,当新模型执行完成之后,再恢复原来模型的状态继续运行。 但是这里存在两个问题,第一是经过编译器编译出来的模型function-call都是merge在一起,此时模型只有一个大的function-call,它无法被抢占;第二是每个function-call的执行时间比较长或者不固定,也会造成抢占时机不固定,影响抢占效果。

为了解决上述的两个问题,地平线在模型编译和系统软件层面都给予了支持,下面分别介绍其实现原理和操作方法:

  • 首先,如果您选择使用QAT方案处理模型,则在 模型编译 阶段,您需要在编译接口中的额外参数配置中添加 max_time_per_fc 选项,用于设置每个function call的执行时间(以微秒为单位),其默认取值为 0 (即不做限制)。 您可以自行设置这个选项控制上板运行阶段个别大function-call的执行时间。假设某function-call执行时间为10ms,当模型编译时 将 max_time_per_fc 设置为 1000,则这个function-call将会被拆分成10个。 而如果您使用PTQ方案处理模型,则在 模型转换 阶段,可以在模型的YAML配置文件中的编译器相关参数( compiler_parameters )中,添加 max_time_per_fc 参数。
  • 其次,需要在推理任务提交时设置 hbUCPSchedParam.priority 参数。按照优先级还可以支持高优抢占嵌套能力。 如:配置 infer 任务优先级小于 254,则为普通任务,不可抢占其他任务。 配置 infer 任务优先级等于 254,则为high抢占任务,可支持抢占普通任务。 配置 infer 任务优先级等于 255,则为urgent抢占任务,可抢占普通任务和high抢占任务。
  • 最后,需要说明的是,BPU抢占功能是系统级别的,即当前进程提交的抢占任务不仅能对进程内的普通任务,也可对其他进程的普通任务实施抢占行为。

应用调优建议

地平线建议的应用调优策略包括工程任务调度和算法任务整合两个方面。

工程任务调度 方面,我们推荐您使用一些workflow调度管理工具,充分发挥不同任务阶段的并行能力。一般应用可以简单拆分为输入前处理、模型推理、输出后处理三个阶段,在简易流程下,其处理流程如下图。

app_optimization_1

充分利用workflow管理实现不同任务阶段并行后,理想的任务处理流程将达到下图效果。

app_optimization_2

算法任务整合 方面,地平线推荐您使用多任务模型。 这样一方面可以在一定程度上避免多模型调度管理的困难;另一方面多任务模型也能充分共享主干网络的计算量,较于使用各个独立的模型,可以在整个应用级别明显减少计算量,从而达到更高的整体性能。 在地平线内部和许多合作客户的业务实践中,多任务也是常见的应用级优化策略。

其他应用开发工具

hrt_model_exec 是一个模型执行工具,可直接在开发板上评测模型的推理性能、获取模型信息。 一方面可以让您在拿到模型时实际了解模型真实性能,另一方面也可以帮助您了解模型可以做到的速度极限,对于应用调优的目标极限具有指导意义。 hrt_model_exec 工具分别提供了查看模型信息 model_info、模型推理 infer 和模型性能分析 perf 功能,工具使用方法请参考 hrt_model_exec工具介绍

UCP还提供了性能分析工具协助您定位应用程序的性能瓶颈,其中 UCP Trace 用于分析应用程序pipline调度的能力,hrt_ucp_monitor 用于监控硬件后端的占用率。 工具使用方法请分别参考 UCP Trace 使用说明hrt_ucp_monitor 工具介绍 章节。