前言

上个月帮一个团队部署Mixtral 8×7B,模型加载阶段卡了15分钟——不是NPU慢,是GE的图编译阶段跑了15分钟。

客户问"TensorRT编译才30秒,GE怎么这么慢?"翻了下编译日志:算子融合Pass跑了8分钟、Memory Planning跑了5分钟、图验证跑了2分钟。不是GE慢,是这个模型的图太复杂(8个expert子图×7B参数),GE在帮你做深度优化。

GE 的定位

CANN五层架构,GE在第3层(昇腾计算编译层):

第1层:AscendCL(编程接口层)
  ↓
第2层:AOL 算子库 + AOE 调优引擎
  ↓
第3层:GE(图引擎)← 你在这
 ├─ 图解析(ONNX/TorchScript → CANN 图)
 ├─ 算子融合(LayerNorm+MatMul → 一个 Kernel)
 ├─ 内存规划(静态分配显存,零碎片)
 └─ 执行计划生成(哪些算子可以并行)
  ↓
第4层:Runtime(算子执行引擎)
  ↓
第5层:驱动层
  ↓
硬件层:达芬奇架构 NPU

类比:AscendCL是"编程语言",GE是"优化编译器",Runtime是"操作系统"。

很多人以为GE和Runtime是同一个东西,其实不是。GE是编译时优化(模型加载阶段),Runtime是运行时调度(推理执行阶段)。GE优化一次,后面每次推理都受益。

工程经验: GE编译慢不怕,怕的是不编译直接扔给Runtime。试过把torch.compile关掉(跳过GE),推理吞吐掉了60%——GE的算子融合和内存规划省掉的HBM读写,Runtime再怎么优化也补不回来。

图编译全流程

GE的编译分6个阶段:

阶段1:图解析(Graph Parsing)

# ONNX模型转换为CANN IR(GE内部流程)
import onnx
from torch.onnx import export

# PyTorch导出ONNX
model = LLaMAForCausalLM.from_pretrained("llama2-7b")
dummy_input = torch.randint(0, 32000, (1, 2048))
torch.onnx.export(
    model, 
    dummy_input, 
    "llama2-7b.onnx",
    input_names=['input_ids'],
    output_names=['logits'],
    dynamic_axes={'input_ids': {0: 'batch', 1: 'seq_len'}}
)

# GE读取ONNX并转换为CANN IR
# 这一步在模型加载时自动完成

ONNX / TorchScript / TensorFlow Graph → CANN IR → 校验

阶段2:图优化 Pass

// GE的Graph Pass示例(融合LayerNorm+MatMul)
// 这是GE内部实现的简化版本
bool LayerNormMatMulFusionPass(Graph& graph) {
    for (auto& node : graph.nodes()) {
        if (node.type() == "LayerNorm") {
            Node* next = node.next();
            if (next && next->type() == "MatMul") {
                // 融合成一个新算子
                Node* fused = graph.createNode("LayerNormMatMul");
                fused->addInput(node.input(0));
                fused->addInput(node.input(1));
                graph.replaceNode(node, fused);
                graph.removeNode(next);
            }
        }
    }
    return true;
}

Subgraph Partition → Operator Fusion → Dead Code Elimination → Constant Folding

阶段3:内存规划(Memory Planning)

# GE的Memory Planning策略(伪代码)
def memory_planning(operators):
    live_ranges = analyze_liveness(operators)  # 存活期分析
    memory_pool = MemoryPool()
    
    for op in operators:
        for tensor in op.inputs + op.outputs:
            if tensor not in memory_pool:
                # 找可以复用的buffer
                reused = find_reusable_buffer(tensor, live_ranges)
                if reused:
                    tensor.memory = reused.memory
                else:
                    tensor.memory = memory_pool.allocate(tensor.size)

静态分析所有算子的输入输出shape → 计算总显存需求 → 分配显存池(预分配,零碎片)

阶段4:算子调度(Operator Scheduling)

分析算子之间的数据依赖 → 构建DAG → 找可以并行的算子 → 生成执行计划

阶段5:Kernel 生成(Kernel Generation)

为每个算子生成NPU Kernel → 调用BiSheng/ATC编译器编译成二进制

阶段6:图验证(Graph Validation)

dry-run验证 → 检查显存 → 生成.om文件

整个流程跑完,生成.om文件(离线模型)。推理时直接加载.om,不用再编译。

为什么需要三层 IR

GE内部有三层IR,每层解决不同的问题:

IR 层级 作用
上层 IR(ONNX IR) 框架无关的中间表示,解决框架兼容性
中层 IR(CANN IR) 昇腾专用的图表示(包含算子属性、shape约束),做算子融合和内存规划
下层 IR(BiSheng IR) 算子级的底层IR(包含Tiling策略、内存布局),生成高效Kernel

为什么需要三层?上层IR解决"框架兼容性"(PyTorch/TensorFlow/MindSpore统一转ONNX IR),中层IR解决"算子融合和内存规划"(ONNX IR太抽象,不知道算子具体实现),下层IR解决"生成高效Kernel"(CANN IR还是图级的,BiSheng IR是算子级的)。

很多人以为IR转换是"多余的开销",其实不是。没有三层IR,要在三种图上都实现一遍算子融合Pass——维护成本爆炸。

算子融合:Graph Pass 示例

GE的算子融合由"Graph Pass"实现。核心规则:

  • 规则1:LayerNorm + MatMul 可以融合(中间结果走L1,不落HBM)
  • 规则2:MatMul + BiasAdd + ReLU 可以融合
  • 规则3:两个连续的MatMul不能融合(它们没有数据依赖,融合了反而抢Cube算力)
# 不复用:LayerNorm和MatMul分开调用
import torch
import torch_npu

# LayerNorm
normalized = torch.nn.functional.layer_norm(x, (128,), weight, bias)
# 写HBM(200ns)
# 再读HBM(200ns)
# MatMul
output = torch.matmul(normalized, weight)
# 总共400ns

# 融合后:LayerNorm+MatMul一个Kernel
# 中间结果走L1(10ns)
# 总共20ns
# 省掉95%的显存访问时间

为什么能融合?融合后的算子中间结果不走HBM,直接走L1缓存。LayerNorm输出→写L1(10ns),MatMul输入←读L1(10ns),总共20ns。不复用:LayerNorm输出→写HBM(200ns),MatMul输入←读HBM(200ns),总共400ns。省掉95%的显存访问时间。

实际融合案例(Qwen2.5-7B,910B单卡):

融合策略 融合前 融合后 提升
LayerNorm + QKV 投影 89 tokens/s 124 tokens/s +39%
Attention(QK^T + Softmax + P×V) 124 tokens/s 178 tokens/s +43%
FFN(两层 MatMul + SiLU) 178 tokens/s 231 tokens/s +30%
全部融合 89 tokens/s 267 tokens/s +200%

工程经验: 融合不是越多越好。试过把"LayerNorm + QKV投影 + Attention + FFN"全部融成一个巨型Kernel,性能反而掉了15%。原因:融合后的Kernel太大,L1缓存装不下,中间结果溢出到HBM。

静态图 vs 动态图

静态图:输入shape固定,GE可以提前做完整优化(算子融合、内存规划、Kernel生成),推理时直接加载优化好的.om,零开销。

动态图:输入shape动态([-1, 3, -1, -1]),GE要在推理时做部分优化(算子融合可以提前做,但内存规划和Tiling要运行时决定),第一次遇到新shape会触发"即时编译"(JIT Compile),有延迟开销。

# 动态图JIT编译示例(PyTorch + torch.compile)
import torch
import torch_npu

model = LLaMAForCausalLM.from_pretrained("llama2-7b").npu()
model = torch.compile(model, backend="npu")  # 开GE编译

# 第一次遇到新shape(batch=1, seq=512)→ JIT编译(可能100-200ms)
output = model(input_ids[:512])

# 第二次遇到同样shape → 直接用缓存的Kernel(零开销)
output = model(input_ids[:512])

# 遇到新shape(batch=1, seq=1024)→ 再次JIT编译
output = model(input_ids[:1024])

为什么这样设计?因为动态图的shape是运行时才知道的,不能提前做完整优化。但算子融合跟shape无关,可以提前做。GE的设计是"能提前做的都提前做,不能提前的运行时做",最大化性能。

工程经验: 动态图的JIT编译开销很大(第一次遇到新shape可能要100-200ms)。解决办法:“预热”(提前用几个典型shape跑一遍,把Kernel缓存起来)。export GE_WARMUP_SHAPES="1,3,512,512;1,3,1024,1024",GE启动时自动预热这些shape。

Memory Planning:静态显存规划

GE用"存活期分析"(Liveness Analysis)找可以复用的buffer。

示例:算子1(LayerNorm)输出 → 算子2(MatMul)输入
不复用:给算子1分配602KB,给算子2分配1.6MB(输入单独分配),总共2.2MB
复用:算子1输出用完立刻给算子2用,省掉602KB的中间buffer
// GE的Memory Planning核心算法(简化版)
struct Tensor {
    string name;
    size_t size;
    int def_op;   // 定义该tensor的算子ID
    int use_op;   // 使用该tensor的算子ID
};

MemoryPlanResult PlanMemory(vector<Tensor>& tensors) {
    // 按def_op排序
    sort(tensors.begin(), tensors.end(), 
         [](const Tensor& a, const Tensor& b) {
             return a.def_op < b.def_op;
         });
    
    MemoryPool pool;
    for (auto& t : tensors) {
        // 找存活期不重叠的buffer复用
        for (auto& buf : pool.buffers) {
            if (buf.last_use_op < t.def_op) {
                // 可以复用
                t.memory_offset = buf.offset;
                buf.last_use_op = t.use_op;
                break;
            }
        }
        // 找不到可复用的,新分配
        if (t.memory_offset == -1) {
            t.memory_offset = pool.alloc(t.size);
        }
    }
    return pool.ToResult();
}

实际收益(Qwen2.5-7B,seq=2048,batch=32):

策略 显存占用 碎片率
不规划(每个算子单独分配) 8.3GB 38%
GE Memory Planning 3.6GB <5%
收益 -57% -33pp

Kernel 生成流程

GE编译的最后一步:给每个算子生成NPU Kernel。

# Kernel生成流程(GE内部)
def generate_kernel(op):
    # 1. 算子描述(CANN IR)
    op_desc = {
        "type": op.type,
        "inputs": [t.shape for t in op.inputs],
        "outputs": [t.shape for t in op.outputs],
        "attrs": op.attrs,
    }
    
    # 2. Tiling策略生成
    tiling = compute_tiling(op_desc)
    # 目标:中间结果刚好塞进L1(1MB)
    
    # 3. Cube/Vector任务分配
    schedule = {
        "cube_ops": [op for op in op.body if op.is_matmul],
        "vector_ops": [op for op in op.body if not op.is_matmul],
    }
    
    # 4. 调用BiSheng/ATC编译器
    kernel_binary = bisheng_compile(op_desc, tiling, schedule)
    
    # 5. 链接成.so
    kernel_so = link_kernel(kernel_binary)
    
    return kernel_so

生成流程:

  1. 算子描述(CANN IR)→ 包含算子类型、输入输出shape、属性
  2. Tiling策略生成 → 根据shape算tile_m/tile_k/block_size,目标:中间结果刚好塞进L1(1MB)
  3. Cube/Vector任务分配 → 矩阵乘给Cube Unit,逐元素运算给Vector Unit
  4. 调用BiSheng / ATC编译器 → 生成NPU二进制代码(.o文件)
  5. 链接 → 把.o链接成.so(动态库),推理时Runtime动态加载

为什么需要BiSheng / ATC编译器?因为GE是"图编译器"(优化算子之间的连接),BiSheng/ATC是"算子编译器"(优化算子内部的实现)。分工不同。

与 TensorRT 对比

维度 TensorRT GE(CANN)
定位 GPU 推理加速库 NPU 图编译+运行时
算子融合 支持(LayerNorm+MatMul等) 支持(更多融合规则,针对达芬奇架构优化)
内存规划 支持(但碎片率比GE高) 支持(存活期分析,碎片率<5%)
动态图 支持(但JIT编译开销大) 支持(部分编译,预热机制)
开源程度 部分开源 全面开源(2025年8月)

核心差异:TensorRT是"推理加速库"(优化算子执行),GE是"图编译器+运行时"(优化整个图)。

为什么有些模型在 GE 编译阶段特别慢?

原因1:模型图太复杂(算子太多)

Transformer一层有7-10个算子,30层就是210-300个算子。GE的算子融合Pass要遍历每个算子看能不能融合,复杂度O(N²)。

# 查看GE编译各阶段耗时
export GE_PROFILING=1
export GE_LOG_LEVEL=INFO

# 跑模型加载
python infer.py --model llama2-7b

# 查看编译日志
grep "GE Profile" ge_log.txt
# 输出示例:
# Graph Parsing: 1200ms
# Graph Pass (Fusion): 4800ms  ← 最慢
# Memory Planning: 3000ms
# Kernel Generation: 2400ms
# Graph Validation: 1200ms

解决:export GE_FUSION_PASS_MODE=fast(跳过激进融合策略)

原因2:动态shape(JIT编译开销)

动态图第一次遇到新shape会触发JIT编译,100-200ms的延迟。

解决:预热(export GE_WARMUP_SHAPES="..."

原因3:Memory Planning太慢(存活期分析O(N²))

模型图复杂时,存活期分析要算每个buffer的存活期,复杂度O(N²)。

解决:export GE_MEM_PLANNING_MODE=fast(改成就绪-使用-释放的简单分析)

原因4:Kernel生成太慢(Tiling搜索)

GE要遍历几十种Tiling参数组合,找最优的那个,耗时。

解决:export GE_TILING_SEARCH_MODE=fast(启发式代替暴力搜索)

工程经验: 编译慢不怕,怕的是不知道慢在哪。export GE_PROFILING=1,GE会输出每个编译阶段的耗时。针对性优化(fast模式),时间从15分钟降到4分钟。

踩坑实录

坑1:融合后的Kernel太大,L1缓存装不下

把LayerNorm+QKV+Attention+FFN全部融成一个Kernel,性能反而掉了15%。

解决:拆成两个Kernel(前一半融合,后一半融合),中间结果走L1。

坑2:动态图JIT编译导致推理延迟波动100-200ms

第一次遇到新shape要编译,后面就好了,但用户体验很差(延迟突然跳一下)。

解决:预热。export GE_WARMUP_SHAPES="..."提前把常见shape都编译好,放.om里。

坑3:Memory Planning在batch大的时候反而增加显存占用

batch>64的时候,GE的存活期分析有点bug——把不该复用的buffer复用了,导致计算结果错误。

解决:export GE_MEM_PLANNING_MODE=safe(保守模式,不复用有风险的buffer)。

坑4:TensorRT编译30秒,GE编译15分钟,差距太大

不是GE慢,是TensorRT的算子融合规则少(只融LayerNorm+MatMul这种简单的),GE的融合规则多(能融的都融),编译时间长但推理性能好。

解决:如果只要快速验证模型正确性,不开GE(关torch.compile),编译30秒搞定。要性能,开GE,等15分钟,推理吞吐翻倍。

https://atomgit.com/cann/ge

https://atomgit.com/cann/metadef

https://atomgit.com/cann/cann-samples

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐