前言

昇腾CANN开源社区包含55个仓库,覆盖从算子开发到运行时执行的完整软件栈。metadef是其中的元数据结构与跨仓库类型定义仓库,为CANN全栈提供统一的数据类型定义、算子原型描述、图结构序列化等基础能力。在异构计算栈中,不同软件层之间需要频繁传递数据,如果每一层都定义自己的数据结构和类型系统,会导致层间数据传递需要进行大量的格式转换,引入不必要的性能开销。metadef的核心设计目标是提供一套统一的元数据结构定义,使得CANN全栈的各个仓库可以共享同一套类型系统,消除层间格式转换开销,并降低类型不一致导致的错误风险。本文深入剖析metadef的设计理念、元数据结构定义机制、跨仓库类型共享机制,以及在昇腾CANN五层架构中的基础支撑作用。

元数据结构在异构计算栈中的重要性

一个完整的异构计算栈包含多个软件层。这些软件层之间需要频繁传递数据。计算图结构、算子原型、张量描述、内存布局信息等数据在不同层之间流动,如果每一层都定义自己的数据结构和类型系统,会导致两个严重问题。

第一,层间数据传递需要进行大量的格式转换,引入不必要的性能开销。格式转换不仅需要额外的计算时间,还可能造成数据精度损失或语义扭曲。在某些情况下,格式转换的开销甚至超过实际计算的开销,成为性能瓶颈。

第二,类型定义的不一致会导致隐晦的bug。类型截断、对齐错误、字节序问题等bug通常难以在测试阶段发现,往往在生产环境中才暴露,修复成本高昂。类型不一致还使得跨仓库的协作变得困难,因为开发者需要了解每个仓库的类型定义细节,才能正确地进行数据传递。

metadef通过提供统一的元数据结构定义,系统性地解决了这些问题。所有CANN仓库共享同一套类型系统,层间数据传递无需格式转换,类型不一致导致的bug在编译期就能发现,大幅提升了软件质量和开发效率。

这种统一类型系统的价值在跨框架支持中尤为突出。不同框架的张量内存布局存在差异,如果算子库需要同时支持多种布局,要么在算子内部做布局转换,要么要求上层框架统一布局。metadef通过统一的张量描述结构,允许算子库声明自己支持的布局,框架根据声明自动插入布局转换节点,实现性能和灵活性的平衡。

metadef架构设计

metadef的架构分为四层:类型定义层、序列化层、版本兼容层、代码生成层。这种分层设计使得各层可以独立演进,同时保持层间接口的稳定性和向后兼容性。

类型定义层使用Protocol Buffers作为元数据描述语言。每个元数据结构定义为一个protobuf Message,包含字段类型、字段编号、字段约束等元信息。使用protobuf的优势在于跨语言支持、向后兼容、高效序列化。protobuf支持C++、Python、Java等多种编程语言,使得metadef可以被不同语言编写的仓库使用。

// WHY: 使用protobuf而非C++结构体来定义元数据结构
// 因为protobuf支持向后兼容(新增字段不影响旧版本解析)
// C++结构体的字段变更会导致二进制不兼容,需要重新编译所有依赖库
// protobuf的向后兼容性对于拥有55个仓库的CANN生态至关重要

syntax = "proto3";

package cann.meta;

// 算子原型定义:描述一个算子的接口契约
// 这个信息被图引擎、框架适配器、算子库共同使用
message OpProto {
    string op_name = 1;  // 算子名称
                          // WHY: 字段编号为1,永远不能修改
                          // 因为修改字段编号会破坏向后兼容性
                          // 旧版本解析新数据时,会错误地解释字段内容
    
    // 输入张量描述列表
    repeated TensorDesc inputs = 2;
    
    // 输出张量描述列表
    repeated TensorDesc outputs = 3;
    
    // 算子属性(非张量参数)
    repeated OpAttr attrs = 4;
    
    // 融合约束:该算子可以与哪些算子融合
    OpFusionConstraint fusion_constraint = 5;
    
    // 性能提示:帮助调度器选择最优实现
    OpPerformanceHint perf_hint = 6;
}

// 张量描述:定义张量的形状、数据类型、内存布局
message TensorDesc {
    repeated int64 shape = 1;  // 形状
                               // -1表示动态维度(编译期未知)
    
    // 数据类型
    // WHY: 使用枚举而非字符串来表示数据类型
    // 因为枚举在编译期有类型检查,字符串只能在运行时检查
    // 枚举还可以方便地添加新的数据类型,保持向后兼容
    DataType dtype = 2;
    
    // 内存布局:NCHW、NHWC、ND(任意维度)
    MemoryLayout layout = 3;
    
    // 对齐要求:某些算子要求张量地址对齐到特定边界
    int64 alignment = 4;
}

// 算子融合约束:描述该算子参与融合的规则
message OpFusionConstraint {
    // 可以作为融合的生产者(即输出被其他算子消费)
    bool can_be_producer = 1;
    
    // 可以作为融合的消费者(即输入来自其他算子)
    bool can_be_consumer = 2;
    
    // 允许的融合模式列表
    repeated string allowed_patterns = 3;
    
    // 融合后的最大算子数量(防止过度融合导致kernel过大)
    int32 max_fused_ops = 4;
}

// WHY: 为重要的枚举类型定义扩展字段
// 因为枚举值会随时间增长,需要预留空间给未来新增的值
// 扩展字段使用1000以上的编号,避免与核心字段冲突
extend google.protobuf.EnumValueOptions {
    // 枚举值的硬件支持情况
    optional HardwareSupport hw_support = 1001;
}

// 硬件支持情况
message HardwareSupport {
    // 是否支持Ascend 910
    bool support_ascend_910 = 1;
    
    // 是否支持Ascend 950PR
    bool support_ascend_950pr = 2;
    
    // 最低驱动版本要求
    string min_driver_version = 3;
}

类型定义层包含以下核心元数据结构。算子原型定义描述一个算子的输入输出张量数量、数据类型约束、属性参数、融合约束等。算子原型定义是算子注册和算子验证的基础,框架在编译期根据算子原型定义检查算子调用的合法性。张量描述定义张量的形状、数据类型、内存布局、对齐要求等。张量是内存分配、算子调度、内核代码生成的基础输入。图结构定义描述一个计算图的结构,包含节点列表、边列表、图输入与输出描述、图级别的属性等。内存分配方案描述计算图中每个张量的内存分配策略,包含张量生命周期分析、显存复用决策、对齐填充等。

序列化层负责将内存中的元数据结构序列化为二进制格式,以及将二进制格式反序列化为内存中的元数据结构。序列化层支持两种格式:protobuf原生二进制格式和JSON格式。二进制格式紧凑、解析快,但不可读;JSON格式可读、可调试,但体积大、解析慢。序列化层根据使用场景自动选择最合适的格式。

版本兼容层处理不同版本metadef之间的兼容性问题。当metadef新增字段或修改字段时,需要保证旧版本生成的元数据仍然可以被新版本解析,以及新版本生成的元数据在旧版本上可以部分解析。版本兼容层通过protobuf的字段编号机制和默认值机制实现兼容性。

代码生成层根据元数据结构定义自动生成多种语言的绑定代码。目前支持生成C++头文件、Python绑定、Go绑定。代码生成层确保多个语言绑定之间的类型定义完全一致,消除手工维护多个语言绑定导致的不同步问题。代码生成在metadef编译时执行,生成的代码直接包含在发布包中,无需用户在目标机器上安装protobuf编译器。

跨仓库类型共享机制

metadef的核心价值在于实现跨仓库的类型共享。CANN开源社区的55个仓库中,超过30个仓库直接或间接依赖metadef定义的元数据结构。这种依赖关系通过以下机制管理。

中心化类型注册表

metadef维护一个中心化的类型注册表,所有CANN仓库的公共类型定义都注册在这个注册表中。每个类型定义包含类型名称、protobuf定义文件、所属仓库、依赖关系。中心化注册表的好处是类型定义有唯一的权威来源,当某个类型需要修改时,开发者只需修改注册表中的定义,代码生成层自动重新生成所有语言绑定,依赖该类型的仓库自动获得更新后的类型定义。

中心化类型注册表还支持类型依赖分析。当一个类型定义发生变更时,注册表可以自动分析哪些仓库会受到影响,并通知这些仓库的维护者进行兼容性测试。这种自动化依赖分析大幅降低了类型变更的风险,使得类型系统可以持续演进而不破坏现有功能。

仓库间类型依赖管理

CANN仓库之间存在复杂的依赖关系。如果类型定义分散在各个仓库中,依赖管理将变得极其复杂。metadef通过统一的类型注册表简化依赖管理。所有仓库的公共类型都通过metadef引入,仓库之间的类型依赖转化为对metadef的依赖。

具体实现中,每个依赖metadef的仓库在构建时从metadef仓库拉取最新的类型定义头文件或Python绑定包。metadef提供CMake集成脚本和Python包安装脚本,使得依赖引入过程自动化。CMake集成脚本自动处理metadef的头文件生成和链接,Python包安装脚本自动处理protobuf运行时依赖的安装。

# WHY: 使用CMake的ExternalProject而非find_package来引入metadef
# 因为find_package要求metadef已经安装在系统中,增加用户负担
# ExternalProject在构建时自动下载和编译metadef,无需用户手动安装
# 这种自动化大幅简化了CANN仓库的构建流程

function(find_metadef)
    # 步骤1:查找metadef安装路径
    # WHY: 先查找已安装的metadef,如果找到则直接使用
    # 因为用户可能已经通过包管理器安装了metadef
    # 使用已安装的版本可以加速构建,避免重复编译
    find_path(METADEF_INCLUDE_DIR 
        NAMES cann/meta/op_proto.proto
        PATHS 
            /usr/local/include
            ${CMAKE_INSTALL_PREFIX}/include
            ${VCPKG_INSTALLED_DIR}/include
    )
    
    if(NOT METADEF_INCLUDE_DIR)
        # metadef未安装,从源码编译
        # WHY: 使用稳定的版本标签而非main分支
        # 因为main分支可能包含未测试的代码,导致构建失败
        # 稳定的版本标签保证可复现的构建结果
        message(WARNING "MetaDef not found, building from source...")
        include(ExternalProject)
        ExternalProject_Add(
            metadef_src
            GIT_REPOSITORY https://atomgit.com/cann/metadef.git
            GIT_TAG v1.2.3  # 稳定版本标签
            CMAKE_ARGS -DCMAKE_INSTALL_PREFIX=${CMAKE_INSTALL_PREFIX}
            BUILD_BYPRODUCTS ${CMAKE_INSTALL_PREFIX}/lib/libmetadef.a
        )
        set(METADEF_DEPENDENCY metadef_src)
        set(METADEF_INCLUDE_DIR ${CMAKE_INSTALL_PREFIX}/include)
    endif()
    
    # 步骤2:生成C++头文件(从.proto文件)
    # WHY: 在构建时生成头文件,而非随源码分发预生成的头文件
    # 因为预生成的头文件可能与protobuf编译器版本不匹配
    # 构建时生成确保使用正确版本的protobuf编译器
    file(GLOB PROTO_FILES "${METADEF_INCLUDE_DIR}/cann/meta/*.proto")
    foreach(proto_file ${PROTO_FILES})
        get_filename_component(proto_name ${proto_file} NAME_WE)
        set(output_header "${METADEF_INCLUDE_DIR}/cann/meta/${proto_name}.pb.h")
        set(output_source "${METADEF_INCLUDE_DIR}/cann/meta/${proto_name}.pb.cc")
        
        # 添加自定义命令来生成头文件
        # WHY: 使用add_custom_command而非execute_process
        # 因为add_custom_command只在.proto文件变更时才重新生成
        # execute_process每次构建都会执行,浪费编译时间
        add_custom_command(
            OUTPUT ${output_header} ${output_source}
            COMMAND protoc --cpp_out=${METADEF_INCLUDE_DIR} ${proto_file}
            DEPENDS ${proto_file}
            COMMENT "Generating C++ headers from ${proto_file}"
        )
        
        # 将生成的头文件添加到目标
        list(APPEND METADEF_GENERATED_HEADERS ${output_header})
        list(APPEND METADEF_GENERATED_SOURCES ${output_source})
    endforeach()
    
    # 步骤3:导出include目录和链接库
    add_library(MetaDef::MetaDef INTERFACE IMPORTED)
    set_target_properties(MetaDef::MetaDef PROPERTIES
        INTERFACE_INCLUDE_DIRECTORIES "${METADEF_INCLUDE_DIR}"
        INTERFACE_LINK_LIBRARIES "protobuf::libprotobuf"
    )
    
    # 添加生成的头文件作为目标的依赖
    add_custom_target(metadef_generate_headers 
        DEPENDS ${METADEF_GENERATED_HEADERS}
    )
    add_dependencies(MetaDef::MetaDef metadef_generate_headers)
endfunction()

算子原型跨仓库共享

算子原型定义是metadef中最重要的元数据结构之一。每个算子仓库在实现算子时,需要同时定义该算子的算子原型,并将算子原型注册到metadef的中心化注册表中。这种机制使得算子原型可以被多个仓库共享。

例如,图引擎在图优化时需要知道每个算子的输入输出约束,这些信息来自算子原型。PyTorch适配器在将PyTorch算子映射到昇腾算子时,也需要查询算子原型以获取昇腾算子的接口定义。如果没有metadef的跨仓库共享机制,每个仓库需要维护一份自己的算子原型定义,不仅工作量重复,而且容易出现定义不一致的问题。

算子原型跨仓库共享的核心挑战是版本管理。当某个算子的接口发生变更时,所有依赖该算子原型的仓库都需要更新。metadef通过语义化版本号和版本协商机制解决这个问题。算子原型的变更被分类为兼容变更和不兼容变更,兼容变更不需要更新依赖仓库,不兼容变更需要通过版本协商机制确保所有依赖仓库都支持新版本。

图结构序列化的跨仓库一致性

计算图在CANN各层之间传递时,需要进行序列化。如果各层使用不同的图结构定义,序列化与反序列化过程需要进行格式转换,引入性能和正确性问题。metadef提供的图结构定义确保了跨仓库的图结构一致性。

框架层将计算图序列化为图结构定义格式,传递给图引擎。图引擎对图结构定义进行优化后,将优化后的图结构定义传递给图编译器。图编译器生成底层指令后,将执行计划序列化为图结构定义的扩展格式,传递给运行时。整个过程中,图结构的定义始终基于metadef的图结构定义,无需任何格式转换。

这种一致性是CANN全栈高效协作的基础。层间数据传递无需格式转换,不仅提升了性能,还消除了格式转换可能引入的bug。图结构定义的跨仓库一致性还使得调试和性能分析变得更加容易,因为开发者可以在不同层之间追踪同一个计算图的表示,无需理解不同层的内部表示差异。

与图引擎的关系

metadef和图引擎都是CANN开源社区中的重要仓库,两者关系紧密。图引擎在图优化过程中需要频繁读取和修改计算图结构,这些图结构的定义来自metadef的图结构定义。具体来说,图引擎在初始化时加载metadef的类型定义,然后基于这些类型定义实现图解析、图优化、图编译等核心功能。

两者在CANN五层架构中的位置不同。metadef位于第5层,为所有上层仓库提供基础类型定义;图引擎位于第3层,依赖metadef提供的类型定义来实现图优化和编译功能。这种分层关系使得metadef可以作为独立的基础组件演进,同时服务于包括图引擎在内的多个上层仓库。

版本管理与向后兼容

metadef作为基础类型定义仓库,其版本变更会影响所有依赖它的仓库。因此,版本管理和向后兼容是metadef设计中的核心考量。metadef采用语义化版本号。主版本号变更表示不兼容的API修改,次版本号变更表示向下兼容的功能新增,修订号变更表示向下兼容的bug修复。

向后兼容通过以下机制保证。第一,protobuf字段编号不变。修改元数据结构时,已有字段的编号不可变更,否则会导致新旧版本解析同一二进制数据得到不同的字段值。新增字段使用新的编号,旧版本解析新数据时忽略不认识的字段编号。第二,默认值机制。新增字段在旧版本中会被解析为默认值,metadef在定义字段时显式指定合理的默认值,确保旧版本解析新数据时行为合理。第三,版本协商机制。序列化数据中嵌入版本号,反序列化端根据版本号决定是否支持该数据。

结尾

metadef作为昇腾CANN全栈的元数据结构与类型定义基础仓库,通过中心化类型注册表、跨仓库类型共享、protobuf版本兼容等机制,系统性地解决了异构计算栈中的类型一致性和版本管理问题。其定义的算子原型、张量、图结构等元数据结构,是CANN各层之间协作的基础契约。对于在昇腾NPU上从事算子开发、图编译、框架适配的开发者,深入理解metadef的元数据结构定义和跨仓库共享机制,是构建正确、高效、可维护的昇腾软件栈的必备基础。

metadef开源仓库:https://atomgit.com/cann/metadef

CANN开源社区:https://atomgit.com/cann

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐