GE 到底做了什么?CANN 图编译全流程深度揭秘
前言
上个月帮一个团队部署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
生成流程:
- 算子描述(CANN IR)→ 包含算子类型、输入输出shape、属性
- Tiling策略生成 → 根据shape算tile_m/tile_k/block_size,目标:中间结果刚好塞进L1(1MB)
- Cube/Vector任务分配 → 矩阵乘给Cube Unit,逐元素运算给Vector Unit
- 调用BiSheng / ATC编译器 → 生成NPU二进制代码(.o文件)
- 链接 → 把.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
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)