第四章:前端解析与模型导入

好,咱们今天聊聊编译器前端最基础也最绕不开的一环——模型解析与导入。说白了,就是让编译器能“看懂”别人家的模型格式。

我刚开始做AI编译器那会儿,最头疼的就是模型格式不统一。ONNX、TFLite、Caffe……每个框架都有自己的“方言”。你想想看,一个模型从训练到部署,中间要经过多少道手?格式转换、算子映射、精度对齐,哪一步都可能出幺蛾子。

这一章,我就带你手撕ONNX和TFLite的解析流程,再聊聊自定义算子怎么注册进去。嗯,都是实战中踩过的坑。

4.1 ONNX模型格式解析

4.1.1 ONNX到底长什么样?

ONNX(Open Neural Network Exchange)本质上是一个protobuf序列化的文件。你用protoc反编译一下,就能看到它的内部结构。

核心数据结构就这几个:

  • ModelProto:最外层,包含版本号、生产者信息、计算图
  • GraphProto:计算图本体,包含节点、输入输出、初始化张量
  • NodeProto:每个算子节点,包含算子类型、输入输出名称、属性
  • TensorProto:权重、偏置等常量数据

我习惯把ONNX模型比作一个“菜谱”。GraphProto是整本菜谱,NodeProto是每个步骤,TensorProto就是食材本身。

关键点:ONNX的算子定义在onnx/onnx.proto中,所有标准算子都有明确的输入输出约束。但实际模型里经常出现“非标准”用法,这时候就需要你手动处理了。

4.1.2 手写一个ONNX解析器

我不会直接用现成的onnxruntime库,那样学不到东西。咱们从protobuf层面开始,自己写一个轻量解析器。

// 伪代码:ONNX模型解析核心流程
void parseONNXModel(const std::string& model_path) {
    // 1. 读取文件到内存
    std::ifstream file(model_path, std::ios::binary);
    std::string buffer((std::istreambuf_iterator<char>(file)), 
                        std::istreambuf_iterator<char>());
    
    // 2. 反序列化protobuf
    onnx::ModelProto model;
    model.ParseFromString(buffer);
    
    // 3. 获取计算图
    const auto& graph = model.graph();
    
    // 4. 遍历所有节点
    for (const auto& node : graph.node()) {
        // 算子类型
        std::string op_type = node.op_type();
        
        // 输入张量名称列表
        std::vector<std::string> inputs;
        for (const auto& name : node.input()) {
            inputs.push_back(name);
        }
        
        // 输出张量名称列表
        std::vector<std::string> outputs;
        for (const auto& name : node.output()) {
            outputs.push_back(name);
        }
        
        // 解析属性
        for (const auto& attr : node.attribute()) {
            if (attr.name() == "kernel_shape") {
                // 处理卷积核大小
            } else if (attr.name() == "strides") {
                // 处理步长
            }
            // ... 其他属性
        }
        
        // 5. 将节点注册到内部IR
        registerNode(op_type, inputs, outputs, attributes);
    }
    
    // 6. 加载权重
    for (const auto& tensor : graph.initializer()) {
        loadWeight(tensor.name(), tensor);
    }
}

这段代码看着简单,但实际坑不少。我曾经在一个项目中,遇到ONNX模型里某个节点的输入名称在initializer里找不到,排查了半天才发现是模型导出时有个权重被优化掉了。

小技巧:解析ONNX时,建议先打印graph.node()的数量和graph.initializer()的数量。如果节点数远大于初始化张量数,说明模型里有很多“非常量”输入,需要额外处理。

4.1.3 ONNX算子映射表

ONNX定义了上百个算子,但实际常用的就二三十个。我整理了一份常用映射表:

ONNX算子 功能描述 常见属性
Conv 卷积操作 kernel_shape, strides, pads, dilations
Relu ReLU激活
Gemm 全连接层 alpha, beta, transA, transB
BatchNormalization 批归一化 epsilon, momentum
MaxPool 最大池化 kernel_shape, strides, pads
Reshape 张量变形 shape
Transpose 维度转置 perm

嗯,这里要注意:ONNX的Gemm算子和PyTorch的Linear层不是完全等价的。ONNX默认transB=1,意味着权重矩阵在乘法前需要转置。我第一次写映射时忘了这茬,结果推理结果全是错的。

4.2 TensorFlow Lite模型导入

4.2.1 TFLite的FlatBuffer结构

TFLite用的是Google的FlatBuffer序列化格式,和ONNX的protobuf完全不同。FlatBuffer的特点是“零反序列化成本”——你直接内存映射就能读取数据。

核心结构体:

  • Model:最外层,包含版本号和算子列表
  • SubGraph:子图,包含张量列表、算子列表、输入输出索引
  • Operator:算子节点,包含算子代码、输入输出张量索引
  • Tensor:张量定义,包含形状、类型、量化参数

我刚开始接触TFLite时,最不习惯的就是它用“索引”而不是“名称”来引用张量。ONNX里每个张量都有个字符串名字,TFLite里全是整数索引。调试起来特别费劲。

4.2.2 解析TFLite模型

// 伪代码:TFLite模型解析
void parseTFLiteModel(const std::string& model_path) {
    // 1. 内存映射文件
    int fd = open(model_path.c_str(), O_RDONLY);
    struct stat sb;
    fstat(fd, &sb);
    void* data = mmap(nullptr, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    
    // 2. 获取FlatBuffer根对象
    const auto* model = tflite::GetModel(data);
    
    // 3. 获取子图(通常只有一个)
    const auto* subgraph = model->subgraphs()->Get(0);
    
    // 4. 遍历所有算子
    for (size_t i = 0; i < subgraph->operators()->size(); i++) {
        const auto* op = subgraph->operators()->Get(i);
        
        // 算子类型(内置算子代码)
        int op_code = model->operator_codes()->Get(op->opcode_index())->builtin_code();
        
        // 输入张量索引
        std::vector<int> inputs;
        for (size_t j = 0; j < op->inputs()->size(); j++) {
            inputs.push_back(op->inputs()->Get(j));
        }
        
        // 输出张量索引
        std::vector<int> outputs;
        for (size_t j = 0; j < op->outputs()->size(); j++) {
            outputs.push_back(op->outputs()->Get(j));
        }
        
        // 5. 根据算子代码创建内部IR节点
        createNodeFromOpCode(op_code, inputs, outputs);
    }
    
    // 6. 解析张量信息
    for (size_t i = 0; i < subgraph->tensors()->size(); i++) {
        const auto* tensor = subgraph->tensors()->Get(i);
        // 获取形状、类型、量化参数
    }
}

注意:TFLite的算子代码是枚举值,不同版本可能不一样。比如BuiltinOperator_CONV_2D在v1.x和v2.x中数值不同。解析时一定要检查模型版本号。

4.2.3 量化模型的特殊处理

TFLite最常用的场景是量化模型。量化参数存在每个张量的quantization字段里,包含scalezero_point

我遇到过最坑的情况:某个张量的量化参数是空的,但模型确实是量化模型。后来发现是TFLite转换时把某些“常量张量”的量化参数省略了,因为常量在编译期就已经量化好了。

处理量化模型时,我建议你这样做:

  • 先检查tensor->quantization()->scale()是否为空
  • 如果为空,检查该张量是否在initializer
  • 如果在,说明是常量,直接读取原始数据
  • 如果不在,说明是动态量化,需要运行时计算

4.3 自定义算子注册

4.3.1 为什么需要自定义算子?

标准算子库再全,也总有覆盖不到的场景。比如你搞了个新的激活函数,或者硬件上有特殊指令加速某个操作。这时候就需要自定义算子。

我参与过一个项目,需要在NPU上实现一个“稀疏卷积”。ONNX和TFLite都没有这个算子,只能自己注册。

4.3.2 注册机制设计

一个通用的算子注册器,核心就三样东西:

  • 算子名称:字符串标识,用于匹配
  • 创建函数:根据输入参数创建算子实例
  • 验证函数:检查输入输出是否合法
// 自定义算子注册器
class OpRegistry {
public:
    using CreatorFunc = std::function<std::shared_ptr<OpNode>(const OpDef&)>;
    using ValidatorFunc = std::function<bool(const OpDef&)>;
    
    // 注册算子
    void registerOp(const std::string& name, 
                    CreatorFunc creator, 
                    ValidatorFunc validator) {
        registry_[name] = {creator, validator};
    }
    
    // 创建算子
    std::shared_ptr<OpNode> createOp(const std::string& name, const OpDef& def) {
        auto it = registry_.find(name);
        if (it == registry_.end()) {
            throw std::runtime_error("Unknown op: " + name);
        }
        
        // 先验证
        if (!it->second.validator(def)) {
            throw std::runtime_error("Invalid op definition: " + name);
        }
        
        // 再创建
        return it->second.creator(def);
    }
    
private:
    std::unordered_map<std::string, std::pair<CreatorFunc, ValidatorFunc>> registry_;
};

// 使用示例:注册一个自定义算子
OpRegistry registry;
registry.registerOp("SparseConv", 
    [](const OpDef& def) -> std::shared_ptr<OpNode> {
        auto node = std::make_shared<SparseConvNode>();
        node->sparsity = def.getAttr<float>("sparsity");
        return node;
    },
    [](const OpDef& def) -> bool {
        return def.hasAttr("sparsity") && 
               def.getAttr<float>("sparsity") > 0.0f;
    }
);

设计建议:注册器最好做成单例模式,全局只有一个实例。这样在模型解析时,不管哪个模块都能访问到已注册的算子。

4.3.3 与模型解析的集成

自定义算子注册好后,怎么和ONNX/TFLite解析器联动呢?

我的做法是:解析器遇到未知算子时,不直接报错,而是先查注册器。如果注册器里有,就按自定义算子处理;如果没有,再报错。

// 在ONNX解析器中集成自定义算子
void parseONNXNode(const onnx::NodeProto& node, OpRegistry& registry) {
    std::string op_type = node.op_type();
    
    // 先查标准算子表
    if (isStandardOp(op_type)) {
        createStandardOp(op_type, node);
        return;
    }
    
    // 再查自定义算子注册器
    try {
        auto custom_op = registry.createOp(op_type, convertToOpDef(node));
        registerCustomOp(custom_op);
    } catch (const std::exception& e) {
        // 都找不到,报错
        throw std::runtime_error("Unsupported op: " + op_type + 
                                 ", " + e.what());
    }
}

这样做的好处是,你可以在不修改解析器核心代码的情况下,随时添加新的算子支持。我曾经在一个项目中,通过这种方式支持了十几种自定义算子,解析器一行代码都没改。

4.4 本章小结

模型解析是编译器前端的第一道坎。ONNX和TFLite虽然格式不同,但核心思路一致:把外部模型转换成编译器内部的统一IR。

我个人觉得,最难的不是解析本身,而是处理各种“边界情况”。比如ONNX里某个节点的输入名称拼写错误,或者TFLite量化参数缺失。这些坑,只有亲手踩过才能记住。

自定义算子注册机制,说白了就是给编译器开个“后门”。标准算子处理不了的,你自己写一个注册进去就行。但要注意,注册的算子一定要有完善的验证逻辑,否则后面优化阶段会出各种奇怪的问题。

好,这一章就到这里。下一章我们聊聊IR的中间表示设计,那是编译器的“灵魂”。


专注资料整理