【算法开发】在X3派上玩转一亿参数量超大Transformer,DIY 专属于你的离线语音识别

一:流程一览

作为本文的开篇,笔者想先领着大家以逐行敲命令的形式过一遍从环境安装模型转换,再到板上部署的全流程。一方面,流程概览可以方便读者用最快的速度领略本文的全貌(后续章节会对每一步涉及的技术细节作完整展开);另一方面,此举虽是囫囵吞枣,但笔者多年来一直坚信 “上手实践 远胜于 精读文档”,最佳的学习路径应该是先跑通全流程再回过头来与文档内容一一对齐(个人看法,不喜可喷)。

Step-1:模型转换的环境准备

# 本文仅限使用 WSL2 Ubuntu20.04 或 任何原生 Ubuntu 系统

# 0. 安装 WeNet 环境 (10min)
#    0.1 为了方便国内用户,在 gitee 上同步了 WeNet 的镜像仓库以方便 git clone
#    0.2 这里不涉及模型训练,因此直接采用 pip 形式安装 cpu 版 torch 而非 WeNet 官方推荐的 conda install 形式
conda create -n horizonbpu python=3.8
conda activate horizonbpu
git clone https://gitee.com/xcsong-thu/wenet.git
cd wenet/runtime/horizonbpu
pip install -r ../../requirements.txt -i https://mirrors.aliyun.com/pypi/simple
pip install torch==1.13.0 torchaudio==0.13.0 torchvision==0.14.0 onnx onnxruntime -i https://mirrors.aliyun.com/pypi/simple

# 1. 安装 Horizon 模型转换工具包及其依赖项 (1min)
wget https://gitee.com/xcsong-thu/toolchain_pkg/releases/download/resource/wheels.tar.gz
tar -xzf wheels.tar.gz
pip install wheels/* -i https://mirrors.aliyun.com/pypi/simple

# 3. 安装交叉编译工具 (1min) (推荐使用 wsl2, 拥有 sudo 权限)
sudo apt-get install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu

Step-2:C++ Demo 的编译

# NOTE: 假设在 wenet/runtime/horizonbpu 路径下 (确保下文build文件夹路径是正确的)

# 0. 编译主程序 decoder_main (20min, 涉及从 github 下载 gflag glog, 可能需要翻墙)
#    编译使用 Step-1 中所安装的系统路径中的 aarch64-linux-gnu-gcc 和 aarch64-linux-gnu-g++,通过 whereis aarch64-linux-gnu-g++ 可以查看其安装位置
cmake -B build -DBPU=ON -DONNX=OFF -DTORCH=OFF -DWEBSOCKET=OFF -DGRPC=OFF -DCMAKE_TOOLCHAIN_FILE=toolchains/aarch64-linux-gnu.toolchain.cmake
cmake --build build

# 1. 不需要等交叉编译完成再进行Step-3, C++ Demo 的编译 和 模型转换 互不干扰,可以并行,请直接移步 Step-3

Step-3:正式开始模型转换

# NOTE: 假设在 wenet/runtime/horizonbpu 路径下 (确保export的相对路径是正确的)

# 0. 配置路径 (1min)
conda activate horizonbpu
export WENET_DIR=$PWD/../../
export PYTHONIOENCODING=UTF-8
export PYTHONPATH=$WENET_DIR:$PYTHONPATH
export PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION='python'

# 1. 下载torch的浮点模型 (3min)
wget https://ghproxy.com/https://github.com/xingchensong/toolchain_pkg/releases/download/conformer_subsample8_110M/model_subsample8_parameter110M.tar.gz
tar -xzf model_subsample8_parameter110M.tar.gz

# 2. 执行转换,pytorch 模型 -> onnx 模型 -> bin 模型 (~40min)
#    由于模型太大(一亿参数量),转换过程对内存要求很高(至少16G)
#    如果内存不够或者等不急 40min,可以直接从如下链接处下载编译好的 encoder.bin / ctc.bin (以chunksize=8,leftchunk=16为例)
#    https://github.com/xingchensong/toolchain_pkg/releases/tag/model_converted_chunksize8_leftchunk16
python3 $WENET_DIR/tools/onnx2horizonbin.py \
  --config ./model_subsample8_parameter110M/train.yaml \
  --checkpoint ./model_subsample8_parameter110M/final.pt \
  --output_dir ./model_subsample8_parameter110M/sample50_chunk8_leftchunk16 \
  --chunk_size 8 \
  --num_decoding_left_chunks 16 \
  --max_samples 50 \
  --dict ./model_subsample8_parameter110M/units.txt \
  --cali_datalist ./model_subsample8_parameter110M/calibration_data/data.list

Step-4:板上部署

# NOTE: 假设在 wenet/runtime/horizonbpu 路径下 (以确保下文fc_base的路径是正确的)

# 0. 将交叉编译的产物 【主程序decoder_main】 和 【所需的动态库】 上传到板端 (1min)
export BPUIP=xxx.xxx.xxx
export DEMO_PATH_ON_BOARD=/path/to/demo
scp build/bin/decoder_main sunrise@$BPUIP:$DEMO_PATH_ON_BOARD
scp fc_base/easy_dnn-src/dnn/*j3*/*/*/lib/libdnn.so sunrise@$BPUIP:$DEMO_PATH_ON_BOARD
scp fc_base/easy_dnn-src/easy_dnn/*j3*/*/*/lib/libeasy_dnn.so sunrise@$BPUIP:$DEMO_PATH_ON_BOARD
scp fc_base/easy_dnn-src/hlog/*j3*/*/*/lib/libhlog.so sunrise@$BPUIP:$DEMO_PATH_ON_BOARD

# 1. 将模型转换的产物 【encoder.bin】和【ctc.bin】 上传到板端,顺带也传一下示例音频和模型字典 (2min)
scp ./model_subsample8_parameter110M/sample50_chunk8_leftchunk16/hb_makertbin_output_encoder/encoder.bin sunrise@$BPUIP:$DEMO_PATH_ON_BOARD
scp ./model_subsample8_parameter110M/sample50_chunk8_leftchunk16/hb_makertbin_output_ctc/ctc.bin sunrise@$BPUIP:$DEMO_PATH_ON_BOARD
scp ./model_subsample8_parameter110M/test_wav.wav sunrise@$BPUIP:$DEMO_PATH_ON_BOARD
scp ./model_subsample8_parameter110M/units.txt sunrise@$BPUIP:$DEMO_PATH_ON_BOARD

# 2. 登录 x3pi 并测试
ssh sunrise@$BPUIP
######################## 以下命令在 X3PI 中执行###########################
cd /path/to/demo
sudo LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH \
  GLOG_logtostderr=1 GLOG_v=2 \
  ./decoder_main \
      --chunk_size 8 \
      --num_left_chunks 16 \
      --rescoring_weight 0.0 \
      --wav_path ./test_wav.wav \
      --bpu_model_dir ./ \
      --unit_path ./units.txt 2>&1 | tee log.txt
  • 模型转换过程的日志输出示例:见附件 hb_mapper_makertbin(1).log

Decoder_main 示例

二:技术详解

Step-1:模型转换的环境准备

环境准备本身没有什么奇技淫巧,这里想重点描述的是:pytorch 版本的升级 对 精度瓶颈速度瓶颈分析 所带来的跨越式的体验提升

在地平线开发者社区官方提供的安装包中,为了兼容训练算法包 海图(HAT),安装的 pytorch 版本为1.10.0。pytorch版本本身对模型转换的精度不会有什么影响,但是不同版本的pytorch所导出的onnx,在节点(node, 或称op)命名上有很大的区别。举个例子,通过 python 一键运行如下代码(假设已经在第一章中成功安装了conda 环境,需要conda activate horizonbpu):

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright [2022-10-28] <sxc19@mails.tsinghua.edu.cn, Xingchen Song>

import os
import torch


config = """
# 模型参数组
model_parameters:
  # 原始Onnx浮点模型文件
  onnx_model: './demo.onnx'
  # 转换的目标AI芯片架构
  march: 'bernoulli2'
  # 模型转换输出的用于上板执行的模型文件的名称前缀
  output_model_file_prefix: 'demo'
  # 模型转换输出的结果的存放目录
  working_dir: './hb_makertbin_output'
  # 指定转换后混合异构模型是否保留输出各层的中间结果的能力
  layer_out_dump: False
  # 转换过程中日志生成级别
  log_level: 'debug'
# 输入信息参数组
input_parameters:
  # 原始浮点模型的输入节点名称
  input_name: 'in'
  # 原始浮点模型的输入数据格式(数量/顺序与input_name一致)
  input_type_train: 'featuremap;'
  # 原始浮点模型的输入数据排布(数量/顺序与input_name一致)
  input_layout_train: 'NCHW;'
  # 原始浮点模型的输入数据尺寸
  input_shape: '1x30x30x10;'
  # 网络实际执行时,输入给网络的batch_size  默认值为1
  # input_batch: 1
  # 在模型中添加的输入数据预处理方法
  norm_type: 'no_preprocess;'
  # 预处理方法的图像减去的均值; 如果是通道均值,value之间必须用空格分隔
  # mean_value: ''
  # 预处理方法的图像缩放比例,如果是通道缩放比例,value之间必须用空格分隔
  # scale_value: ''
  # 转换后混合异构模型需要适配的输入数据格式(数量/顺序与input_name一致)
  input_type_rt: 'featuremap;'
  # 输入数据格式的特殊制式
  input_space_and_range: ''
  # 转换后混合异构模型需要适配的输入数据排布(数量/顺序与input_name一致)
  input_layout_rt: 'NCHW;'
# 校准参数组
calibration_parameters:
  # 模型校准使用的标定样本的存放目录
  cal_data_dir: './calibration_data/'
  # 开启图片校准样本自动处理(skimage read resize到输入节点尺寸)
  preprocess_on: False
  # 校准使用的算法类型
  calibration_type: 'default'
  # max 校准方式的参数
  max_percentile: 1.0
  # 强制指定OP在CPU上运行
  # run_on_cpu: ''
  # 强制指定OP在BPU上运行
  # run_on_bpu:  ''
# 编译参数组
compiler_parameters:
  # 编译策略选择
  compile_mode: 'latency'
  # 是否打开编译的debug信息
  debug: False
  # 模型运行核心数
  core_num: 1
  # 模型编译的优化等级选择
  optimize_level: 'O3'
"""


with open("./config_onnx2bin.yaml", "w") as f:
    f.write(config)


def to_numpy(tensor):
    if tensor.requires_grad:
        return tensor.detach().cpu().numpy()
    else:
        return tensor.cpu().numpy()


class SubLayer(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.nn = torch.nn.Conv2d(30, 30, 1, 1)
        self.act = torch.nn.SiLU()

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.act(self.nn(x))


class Layer(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.sublayer1 = SubLayer()
        self.sublayer2 = SubLayer()

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.sublayer2(self.sublayer1(x))

# NOTE(xcosng): Model -> Layer -> SubLayer -> OP 四层级结构
class Model(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.layer1 = Layer()
        self.layer2 = Layer()

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.layer2(self.layer1(x))


input_data = torch.randn(1, 30, 30, 10)
demo = Model()

torch.onnx.export(
    demo, input_data, "./demo.onnx", opset_version=11,
    input_names=['in'], output_names=['out']
)

os.makedirs("./calibration_data", exist_ok=True)
to_numpy(input_data).tofile("./calibration_data/0.bin")

os.system(
    "hb_mapper makertbin \
        --model-type \"onnx\" \
        --config \"./config_onnx2bin.yaml\""
)

上述代码定义了一个 Model → Layer → SubLayer → OP 四层级结构,这种多层级的模型定义在各种 SOTA 模型结构中是非常普遍的。当 torch 版本为 1.10.0 时上述代码获得的编译结果为:

torch 1.10.0 结果

可见,Node的命名采用了 “optype+数字” 的形式,这种形式的缺点是:当模型 Layer/SubLayer 数量非常多(比如本文一亿参数量的Transformer,包含的 OP 有上千个),*我们很难一眼定位 Conv_xx 到底是第几层的第几个卷积*。通常一个量化明显掉点的模型,会从中间某一个OP开始有鲜明的 Cosine Similarity 损失,在当前的命名格式下,为了找到这个OP在原始模型中的位置(第x Layer 的 第 y SubLayer),我们需要从头开始一个一个数,这无疑是效率低下的。当然,随着对模型细节的熟悉,定位的速度会越来越快,但这不能从根本上解决效率问题。

相反,当 torch 版本升级到 1.13.0 时我们获得的编译结果为:

torch 1.13.0 结果

可见,Node的命名采用了 “Layer + SubLayer + Attribute + OP” 的形式,一眼定位,一眼丁真,大大节省了开发人员定位 精度问题(哪层的OP相似度下降严重) or 速度问题(哪层的OP跑在CPU) 的时间

Step-2:C++ Demo 的编译

由于X3板端内存有限,编译C++ Demo时笔者采用了交叉编译的形式,在开发机上 sudo 安装 aarch gcc 即可。

至于使用 C++ 实现 BPU 模型的板上推理,实现推理的逻辑本身是一件很容易的事情,无论是使用 python 实现亦或是 C++ 实现,其流程都是固定的,也即:

流程图

关于这四个步骤的API调用范例,官方 C++ 文档中都给出了比较详细的 know-how 示例,但是大多数都是单模型 + 单输入的简单case,在语音识别模型中,会涉及到 多模型(多个bin串联) + 多输入(一个bin有多个输入) 的情况,这里给出本文的针对性示例:

// BPUAsrModel 类定义

using hobot::easy_dnn::Model;
using hobot::easy_dnn::DNNTensor;
using hobot::easy_dnn::TaskManager;
using hobot::easy_dnn::ModelManager;

class BPUAsrModel : public AsrModel {
 public:
  BPUAsrModel() = default;
  ~BPUAsrModel();
  BPUAsrModel(const BPUAsrModel& other);
  void Read(const std::string& model_dir);
  void PrepareEncoderInput(const std::vector<std::vector<float>>& chunk_feats);
  // 其他成员函数...

 protected:
  void ForwardEncoderFunc(const std::vector<std::vector<float>>& chunk_feats,
                          std::vector<std::vector<float>>* ctc_prob) override;

 private:
  // models
  std::shared_ptr<Model> encoder_model_ = nullptr;
  std::shared_ptr<Model> ctc_model_ = nullptr;

  // input/output tensors, 使用vector方便应对单模型多输入的情况
  std::vector<std::shared_ptr<DNNTensor>> encoder_input_, encoder_output_;
  std::vector<std::shared_ptr<DNNTensor>> ctc_input_, ctc_output_;
  
  // 其他成员变量...
};

1. 加载模型(多个模型的case)

// NOTE: 这里需要加载两个bin:encoder.bin 和 ctc.bin

void BPUAsrModel::Read(const std::string& model_dir) {
  std::string encoder_model_path = model_dir + "/encoder.bin";
  std::string ctc_model_path = model_dir + "/ctc.bin";

  // 0. 初始化 managers,ModelManager 遵循单例模式 GetInstance() 永远只会得到 Singleton
  ModelManager* model_manager = ModelManager::GetInstance();

  // 1. 分别加载 encode.bin 和 ctc.bin
  std::vector<Model*> models;
  
  model_manager->Load(models, encoder_model_path);
  encoder_model_.reset(model_manager->GetModel([](Model* model) {
    return model->GetName().find("encoder") != std::string::npos;
  }));
  
  model_manager->Load(models, ctc_model_path);
  ctc_model_.reset(model_manager->GetModel([](Model* model) {
    return model->GetName().find("ctc") != std::string::npos;
  }));

  // 其他处理...
 }

2. 准备推理数据(多个输入的case)

void BPUAsrModel::PrepareEncoderInput(...) {
  // encoder_input_ 的类型: std::vector<std::shared_ptr<DNNTensor>>
  // 使用 vector 存储模型输入的Tensor,可以很方便应对单模型多输入的情况
  // 这里首先遍历 vector 中的所有输入,初始化并置零
  for (auto& single_input : encoder_input_) {
    auto feat_ptr = reinterpret_cast<float*>(single_input->sysMem[0].virAddr);
    memset(single_input->sysMem[0].virAddr, 0, single_input->properties.alignedByteSize);
  }
  
  // 其他数值上的特殊处理...
}

3. 模型推理(多个模型串联的case)

void BPUAsrModel::ForwardEncoderFunc(...) {
  // 0. 初始化 managers,TaskManager 遵循单例模式 GetInstance() 永远只会得到 Singleton
  TaskManager* task_manager = TaskManager::GetInstance();

  // 1. Forward Encoder.bin
  PrepareEncoderInput(chunk_feats);
  for (auto& tensor : encoder_input_) {
    // Cpu对内存进行操作之后,有些数据有可能残留在cache当中
    // 因此必须要对刚刚写完的数据进行flush to mem的操作, 将残留的数据刷写到mem中去,
    // 保证mem中的数据是完整的,也就是clean操作,这样才能保证 BPU 读取 mem 的数据是正确的
    hbSysFlushMem(&(tensor->sysMem[0]), HB_SYS_MEM_CACHE_CLEAN);
  }
  auto infer_task = task_manager->GetModelInferTask(1000);
  infer_task->SetModel(encoder_model_.get());
  infer_task->SetInputTensors(encoder_input_);
  infer_task->SetOutputTensors(encoder_output_);
  infer_task->RunInfer();
  infer_task->WaitInferDone(1000);
  infer_task.reset();
  for (auto& tensor : encoder_output_) {
    // BPU 对内存进行操作之后,数据直接放在mem中这时候,CPU若是想获取mem中的数据,
    // 需要对cache进行ivaldate没置,这样CPU就会过cache缓存,直接从mem获取数据;
    hbSysFlushMem(&(tensor->sysMem[0]), HB_SYS_MEM_CACHE_INVALIDATE);
  }

  // 2. Forward CTC.bin
  PrepareCtcInput();  // encoder.bin 的输出会当做 ctc.bin 的输入,也即两者串联
  for (auto& tensor : ctc_input_) {
    hbSysFlushMem(&(tensor->sysMem[0]), HB_SYS_MEM_CACHE_CLEAN);
  }
  infer_task = task_manager->GetModelInferTask(1000);
  infer_task->SetModel(ctc_model_.get());
  infer_task->SetInputTensors(ctc_input_);
  infer_task->SetOutputTensors(ctc_output_);
  infer_task->RunInfer();
  infer_task->WaitInferDone(1000); // 1000 是 1000ms, 如果模型单次推理时间超过1000ms 会 crash,所以这个数值需要远大于推理耗时
  infer_task.reset();
  for (auto& tensor : ctc_output_) {
    hbSysFlushMem(&(tensor->sysMem[0]), HB_SYS_MEM_CACHE_INVALIDATE);
  }
  
  // 其他后处理...
}

Step-3:正式开始模型转换

1. 一行代码 改写Transformer模型

使用工具链去转换 NLP领域的 原生 Transformer 模型,体验可能会是非常糟糕的(甚至会在转换过程中直接报错)。这是因为 NLP 中的 Transformer,输入 tensor 的维度通常是二维或三维,类型既包含 float 也包含 long 。而XJ3芯片在设计时只着重考虑了视觉任务,通常都是浮点的四维图像输入,工具链也只对这类视觉模型有比较极致的体验优化。

那么,为了转换 NLP 类的 Transformer,我们是否需要重头训练一个四维数据流的模型呢?答案显然是否定的,本文通过等价替换抽象封装,实现了一行代码将 原生Transformer 等价改写为 BPU友好的Transformer:

# 一键完成 3D数据流 Transformer 等价转换 4D数据流 Transformer 
Encoder4D = wenet.bin.export_onnx_bpu.BPUTransformerEncoder(Encoder3D)

这里的 BPUTransformerEncoder 就像是科幻电影中的“外骨骼机甲”一样,其内核没变(权重参数值没变),但是功能上实现了针对性升级。具体而言,在 BPUTransformerEncoder 的构造过程中,会逐 OP 遍历原生的 Encoder3D,并对其中的 BPU 不友好的 OP 实施 等价改写,比如针对 linear 层,有:

import torch

class BPULinear(torch.nn.Module):                                                                                                                                                     [817/2069]    """Refactor torch.nn.Linear or pointwise_conv"""
    # 使用 nn.Conv2d 模拟 nn.Linear
    def __init__(self, module):
        super().__init__()
        # Unchanged submodules and attributes
        original = copy.deepcopy(module)
        self.idim = module.weight.size(1)
        self.odim = module.weight.size(0)

        # Modify weight & bias
        self.linear = torch.nn.Conv2d(self.idim, self.odim, 1, 1)
        # (odim, idim) -> (odim, idim, 1, 1)
        self.linear.weight = torch.nn.Parameter(                                                                                                                                                            module.weight.unsqueeze(2).unsqueeze(3))
        self.linear.bias = module.bias

        self.check_equal(original)

    # 自动验证转换前后模型输出是否一致
    def check_equal(self, module):
        random_data = torch.randn(1, 8, self.idim)
        original_result = module(random_data)
        random_data = random_data.transpose(1, 2).unsqueeze(2)
        new_result = self.forward(random_data)
        np.testing.assert_allclose(
            to_numpy(original_result),
            to_numpy(new_result.squeeze(2).transpose(1, 2)),
            rtol=1e-02, atol=1e-03)

    # 在外部看来,BPULinear 调用形式 和 torch.nn.Linear 调用形式 完全一致
    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """Linear with 4-D dataflow.
        Args:
            x (torch.Tensor): (batch, in_channel, 1, time)
        Returns:
            (torch.Tensor): (batch, out_channel, 1, time).
        """
        return self.linear(x)

# 原生 Transformer 训练过程中普遍使用的 Linear
OrigLinear = torch.nn.Linear(10, 10)
# 训练完成后将该实例改写为 BPULinear,
# 使用 torch.nn.Conv2D 模拟 torch.nn.Linear, 前者对工具链转换模型而言更加友好
NewLinear = BPULinear(OrigLinew)

在成功改写 Linear 的基础上,进一步地,我们可以完成 Transformer 中最常见的 FeedForward 层(FFN)改写:

class BPUFFN(torch.nn.Module):
    """Refactor wenet/transformer/positionwise_feed_forward.py::PositionwiseFeedForward
    """
    # 使用 BPULinear 模拟 FeedForward 层中的两个 torch.nn.Linear
    def __init__(self, module):
        super().__init__()                                                                                                                                                                              # Unchanged submodules and attributes
        original = copy.deepcopy(module)
        self.activation = module.activation

        # 1. Modify self.w_x
        self.w_1 = BPULinear(module.w_1)
        self.w_2 = BPULinear(module.w_2)

        self.check_equal(original)

    # 自动验证转换前后模型输出是否一致
    def check_equal(self, module):
        random_data = torch.randn(1, 8, self.w_1.idim)
        original_out = module(random_data)
        random_data = random_data.transpose(1, 2).unsqueeze(2)
        new_out = self.forward(random_data)
        np.testing.assert_allclose(
            to_numpy(original_out),
            to_numpy(new_out.squeeze(2).transpose(1, 2)),
            rtol=1e-02, atol=1e-03)

    # 在外部看来,BPUFFN 调用形式 和 原生 FFN 调用形式 完全一致
    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """Forward function.

        Args:
            xs: input tensor (B, D, 1, L)
        Returns:
            output tensor, (B, D, 1, L)
        """
        return self.w_2(self.activation(self.w_1(x)))

除此之外,对于其他层的改写(如 MultiheadAttention)都是类似的,这里不再赘述,感兴趣可以仔细研读开源代码。

2. 一句命令 走完转换全流程

一个完整pytorch模型到bpu模型的转换流程,一般要经过如下四步:

  1. pytorch 模型 转 onnx 模型
  2. 构造 Calibration 数据
  3. 构造 config.yaml
  4. 调用 hb_mapper 执行 onnx 转 bpu bin

在 WeNet 开源的代码中,我们用人民群众喜闻乐见的 python 把这四个步骤 “” 到了一起,使用如下命令,就可走完全流程。

python3 $WENET_DIR/tools/onnx2horizonbin.py \
  --config ./model_subsample8_parameter110M/train.yaml \
  --checkpoint ./model_subsample8_parameter110M/final.pt \
  --output_dir ./model_subsample8_parameter110M/sample50_chunk8_leftchunk16 \
  --chunk_size 8 \
  --num_decoding_left_chunks 16 \
  --max_samples 50 \
  --dict ./model_subsample8_parameter110M/units.txt \
  --cali_datalist ./model_subsample8_parameter110M/calibration_data/data.list

其中

  • config (描述了模型配置,几层layer等)
  • checkpoint (pytorch 浮点模型)
  • output_dir (.bin 文件输出目录)
  • chunk_size (跟识别有关的解码参数)
  • num_decoding_left_chunks (跟识别有关的解码参数)
  • max_samples (使用多少句数据制作calibration data)
  • dict (字典)
  • cali_datalist (描述了标定数据的位置)

更具体地,本着 pythonic 的原则,在 onnx2horizonbin.py 中,我们将 config.yaml 的构造模板化并在python代码中以填充字段的形式对模板实例化:

def generate_config(enc_session, ctc_session, args):
    # 如下的template字符串是config.yaml的模板化字符串
    # 通过.format填充相关字段,使得DIY变得更方便
    template = """
# 模型参数组
model_parameters:                                                                                                                                                                                 # 原始Onnx浮点模型文件
  onnx_model: '{}'
  # 转换的目标AI芯片架构
  march: 'bernoulli2'
  # 模型转换输出的用于上板执行的模型文件的名称前缀
  output_model_file_prefix: '{}'
  # 模型转换输出的结果的存放目录
  working_dir: '{}'
  # 指定转换后混合异构模型是否保留输出各层的中间结果的能力
  layer_out_dump: False
  # 转换过程中日志生成级别
  log_level: 'debug'
# 输入信息参数组
input_parameters:
  # 原始浮点模型的输入节点名称
  input_name: '{}'
  # 原始浮点模型的输入数据格式(数量/顺序与input_name一致)
  input_type_train: '{}'
  # 原始浮点模型的输入数据排布(数量/顺序与input_name一致)
  input_layout_train: '{}'
  # 原始浮点模型的输入数据尺寸
  input_shape: '{}'
  # 网络实际执行时,输入给网络的batch_size  默认值为1
  # input_batch: 1
  # 在模型中添加的输入数据预处理方法
  norm_type: '{}'
  # 预处理方法的图像减去的均值; 如果是通道均值,value之间必须用空格分隔
  # mean_value: ''
  # 预处理方法的图像缩放比例,如果是通道缩放比例,value之间必须用空格分隔
  # scale_value: ''
  # 转换后混合异构模型需要适配的输入数据格式(数量/顺序与input_name一致)
  input_type_rt: '{}'
  # 输入数据格式的特殊制式
  input_space_and_range: ''
  # 转换后混合异构模型需要适配的输入数据排布(数量/顺序与input_name一致)
  input_layout_rt: '{}'
# 校准参数组
calibration_parameters:
  # 模型校准使用的标定样本的存放目录
  cal_data_dir: '{}'
  # 开启图片校准样本自动处理(skimage read resize到输入节点尺寸)
  preprocess_on: False
  # 校准使用的算法类型                                                                                                                                                                [145/2559]  calibration_type: '{}'
  # max 校准方式的参数
  max_percentile: 1.0
  # 强制指定OP在CPU上运行
  run_on_cpu: '{}'
  # 强制指定OP在BPU上运行
  run_on_bpu: '{}'
# 编译参数组
compiler_parameters:
  # 编译策略选择
  compile_mode: 'latency'
  # 是否打开编译的debug信息
  debug: False
  # 模型运行核心数
  core_num: 1
  # 模型编译的优化等级选择
  optimize_level: 'O3'
"""
    output_dir = os.path.realpath(args.output_dir)
    cal_data_dir = os.path.join(output_dir, 'cal_data_dir')
    os.makedirs(cal_data_dir, exist_ok=True)
    # 在导出 onnx 的过程中将各种属性(shape / layout 等)写入 onnx 模型的 custom_metadata_map
    # 使得shape等信息的推断变成了全自动进行,而不需要人工在config.yaml中指定。
    # 对于多输入的模型以及需要经常变化shape的模型,这种模板化的方式相比所有字段写死的config.yaml
    # 其优势在于:不需要任何人工修改就可以适配
    enc_dic = enc_session.get_modelmeta().custom_metadata_map
    enc_onnx_path = os.path.join(output_dir, 'encoder.onnx')
    enc_log_path = os.path.join(output_dir, 'hb_makertbin_output_encoder')
    enc_cal_data = ";".join(
        [cal_data_dir + "/" + x for x in enc_dic['input_name'].split(';')])
    ctc_dic = ctc_session.get_modelmeta().custom_metadata_map
    ctc_onnx_path = os.path.join(output_dir, 'ctc.onnx')
    ctc_log_path = os.path.join(output_dir, 'hb_makertbin_output_ctc')
    ctc_cal_data = ";".join(
        [cal_data_dir + "/" + x for x in ctc_dic['input_name'].split(';')])
    # config.yaml的模板字符串,通过.format填充相关字段,使得DIY变得更方便
    enc_config = template.format(
        enc_onnx_path, "encoder", enc_log_path,
        enc_dic['input_name'], enc_dic['input_type'],
        enc_dic['input_layout_train'], enc_dic['input_shape'],
        enc_dic['norm_type'], enc_dic['input_type'], enc_dic['input_layout_rt'],
        enc_cal_data, args.calibration_type, args.extra_ops_run_on_cpu, "")
    ctc_config = template.format(
        ctc_onnx_path, "ctc", ctc_log_path,
        ctc_dic['input_name'], ctc_dic['input_type'],
        ctc_dic['input_layout_train'], ctc_dic['input_shape'],
        ctc_dic['norm_type'], ctc_dic['input_type'], ctc_dic['input_layout_rt'],
        ctc_cal_data, "default", "", "")
    # 模板实例化后导出为真实的 config.yaml
    with open(output_dir + "/config_encoder.yaml", "w") as enc_yaml:
        enc_yaml.write(enc_config)
    with open(output_dir + "/config_ctc.yaml", "w") as ctc_yaml:
        ctc_yaml.write(ctc_config)

encoder.onnx 模型的 custom_metadata_map 示例,config.yaml所需的各种关键信息在导出onnx时就已经被记录下来

同样本着 pythonic 的原则,对于调用 hb_mapper 执行 onnx 转 bpu bin的过程,我们也将其封装到了 python 代码中,具体而言,使用 os 包直接执行相关命令:

import os

os.system(
    "cd {} && mkdir -p hb_makertbin_log_ctc".format(output_dir) +
    " && cd hb_makertbin_log_ctc &&" +
    " hb_mapper makertbin --model-type \"onnx\" --config \"{}\"".format(
        output_dir + "/config_ctc.yaml")
)

综上,我们对如下这四个步骤实现了完完全全的 python化封装一体化串联 ,真正实现了一句命令(python3 $WENET_DIR/tools/onnx2horizonbin.py …)走完全部转换流程

  1. pytorch 模型 转 onnx 模型
  2. 构造 Calibration 数据
  3. 构造 config.yaml
  4. 调用 hb_mapper 执行 onnx 转 bpu bin

三:DEMO展示

硬件配置

模型配置

解码速度对比 (单核单线程,量化后的模型)

展示视频 (chunk8 leftchunk 16)

Server - Client 示例

Decoder_main 示例

hb_mapper_makertbin11_20221204220515.log

你好 , apt-get install libopenfst-dev安装了 OpenFst但是在 CMake 无法找到 OpenFst 的配置文件,OpenFstConfig.cmakeopenfst-config.cmake这是为什么

代码仓库在哪里