前言
你训练好一个模型,导出 ONNX,转成 CANN 的 OM 模型。推理时发现:延迟 89ms,吞吐只有 1200 samples/s。
你开始调优:
- 加
--op_precision_mode=force_fp16→ 延迟 72ms(快 19%) - 加
--auto_tune_mode=GA→ 延迟 65ms(再快 10%) - 再加
--enable_l2_fusion=1→ 延迟 48ms(再快 35%!)
最后一个优化(--enable_l2_fusion=1)是什么?它是 GE 图优化 Pass 里的 L2FusionPass。
GE(Graph Engine)是 CANN 的图编译引擎,有一堆优化 Pass(几十个)。但真正影响推理性能的只有几个。这篇文章帮你把最关键的 Pass 挑出来,附带开关代码和性能数据。
GE 图优化 Pass 全景
先看看 GE 到底有多少 Pass:
python
# list_ge_passes.py - 列出所有 GE Pass
import subprocess
import re
def list_ge_passes():
"""调用 ge 命令行工具列出所有 Pass"""
result = subprocess.run(
["ge", "--list_passes"],
capture_output=True,
text=True
)
passes = []
for line in result.stdout.split('\n'):
# 解析 Pass 名称(格式:PassName: description)
match = re.match(r'^(\w+):\s*(.*)$', line)
if match:
passes.append({
"name": match.group(1),
"desc": match.group(2)
})
return passes
# 列出所有 Pass
all_passes = list_ge_passes()
print(f"GE Pass 总数: {len(all_passes)}")
for p in all_passes[:10]: # 打印前 10 个
print(f" {p['name']}: {p['desc']}")
# 输出(示例):
# GE Pass 总数: 47
# ConstantFoldingPass: 常量折叠
# OpFusionPass: 算子融合
# MemoryReusePass: 内存复用
# L2FusionPass: L2 融合优化
# DeadCodeEliminationPass: 死代码消除
# ...
47 个 Pass! 全部开启?没必要,有些 Pass 对推理性能没影响(甚至负优化)。
关键 Pass 1:常量折叠(ConstantFoldingPass)
作用:编译期算掉所有常数子图。
典型案例:
python
# 模型里有这样的子图(YOLOv5 的锚框计算)
import torch
import torch.nn as nn
class YOLOHead(nn.Module):
def forward(self, x):
# 常量计算:锚框尺寸(编译期就能算完)
anchors = torch.tensor([(10,13), (16,30), (33,23)], device=x.device)
anchors = anchors.repeat(x.shape[0], 1, 1) # 常量操作
# 动态计算:检测框(推理期才能算)
boxes = self.detect(x)
return anchors, boxes
没开 ConstantFoldingPass 时:
- 每次推理都重新算
anchors(虽然它是常量) - 延迟增加 3~5ms(取决于常量子图复杂度)
开了 ConstantFoldingPass 后:
- 编译期把
anchors直接算出来,存在模型文件里 - 推理期直接读结果,不占计算资源
开关方法:
python
# enable_constant_folding.py
import torch
from torch_npu.contrib import transfer
# 方法1:通过环境变量开启(推荐)
import os
os.environ['GE_OPTIMIZATION_PASSES'] = 'ConstantFoldingPass:1'
# 方法2:通过 CANN 配置文开启
config = {
"ge": {
"opt_passes": {
"ConstantFoldingPass": 1, # 1=开启,0=关闭
}
}
}
# 导出模型(带常量折叠优化)
dummy_input = torch.randn(1, 3, 640, 640).npu()
torch.onnx.export(
model,
dummy_input,
"yolov5_optimized.onnx",
**kwargs
)
# 转 OM 模型时指定优化 Pass
os.system(f"""
atc --model=yolov5_optimized.onnx \
--framework=5 \
--output=yolov5_optimized \
--input_format=NCHW \
--op_precision_mode=force_fp16 \
--opt_passes=ConstantFoldingPass:1
""")
性能数据(YOLOv5-s):
| 配置 | 推理延迟 (ms) | 吞吐 (samples/s) |
|---|---|---|
| 基线(无优化) | 18.7 | 534 |
| + ConstantFoldingPass | 15.2 | 657 |
加速比 :18.7 → 15.2 = 18.7% ↑
关键 Pass 2:算子融合(OpFusionPass)
作用:把多个小算子融合成一个大算子,减少 Kernel Launch 开销。
典型案例:
python
# BERT 的 Embedding 层(3 个算子)
import torch
import torch.nn as nn
class BertEmbeddings(nn.Module):
def __init__(self, config):
super().__init__()
self.word_embeddings = nn.Embedding(config.vocab_size, config.hidden_size)
self.position_embeddings = nn.Embedding(config.max_position_embeddings, config.hidden_size)
self.token_type_embeddings = nn.Embedding(config.type_vocab_size, config.hidden_size)
self.LayerNorm = nn.LayerNorm(config.hidden_size)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
def forward(self, input_ids, token_type_ids=None):
# 算子1:Word Embedding
words = self.word_embeddings(input_ids)
# 算子2:Position Embedding
position_ids = torch.arange(input_ids.shape[1], device=input_ids.device).unsqueeze(0)
positions = self.position_embeddings(position_ids)
# 算子3:Token Type Embedding
if token_type_ids is None:
token_type_ids = torch.zeros_like(input_ids)
token_types = self.token_type_embeddings(token_type_ids)
# 逐元素加(3 个算子)
embeddings = words + positions + token_types
# LayerNorm + Dropout
embeddings = self.LayerNorm(embeddings)
embeddings = self.dropout(embeddings)
return embeddings
没开 OpFusionPass 时:
- 上面有 7 个算子(3 个 Embedding + 3 个加法 + LayerNorm)
- 每个算子都要 Launch Kernel,开销 2~3μs/算子
- 总 Launch 开销:14~21μs
开了 OpFusionPass 后:
- 融合成 1 个算子 (
BertEmbeddingsFused) - Launch 开销:2~3μs(只有一次)
- 节省:12~18μs
开关方法:
python
# enable_op_fusion.py
import os
# 开启算子融合(默认就是开启的,但可以调等级)
os.environ['GE_FUSION_RULE'] = '1' # 1=保守融合,2=激进融合
# 融合规则配置(JSON 格式)
fusion_config = {
"rules": [
{
"pattern": ["Embedding", "Add", "LayerNorm"], # 融合模式
"replacement": "FusedEmbeddingAddLayerNorm", # 融合后的算子名
"condition": "input_dtype==float16" # 融合条件
},
{
"pattern": ["MatMul", "BiasAdd", "Relu"], # MatMul + BiasAdd + Relu
"replacement": "FusedDense",
"condition": "weights_shape[0] % 16 == 0"
}
]
}
# 保存配置到文件
import json
with open("fusion_rules.json", "w") as f:
json.dump(fusion_config, f, indent=2)
# 转 OM 时指定融合规则
os.system(f"""
atc --model=bert.onnx \
--framework=5 \
--output=bert_fused \
--op_precision_mode=force_fp16 \
--fusion_rules_file=fusion_rules.json
""")
性能数据(BERT-base):
| 配置 | 推理延迟 (ms) | Kernel Launch 次数 |
|---|---|---|
| 基线(无融合) | 28.3 | 47 |
| + OpFusionPass(保守) | 24.1 | 31 |
| + OpFusionPass(激进) | 21.7 | 18 |
加速比 :28.3 → 21.7 = 23.3% ↑
关键 Pass 3:内存复用(MemoryReusePass)
作用:让生命周期不重叠的 Tensor 共用同一块显存,降低显存峰值。
典型场景:
python
# Transformer 的 Decoder(自回归生成)
import torch
import torch.nn as nn
class TransformerDecoder(nn.Module):
def forward(self, x, cache=None):
# Layer 1
x1 = self.self_attn1(x, cache=cache)
x1 = self.ffn1(x1)
# Layer 2
x2 = self.self_attn2(x1, cache=cache) # x1 生命周期结束
x2 = self.ffn2(x2)
# Layer 3
x3 = self.self_attn3(x2, cache=cache) # x2 生命周期结束
x3 = self.ffn3(x3)
# ... 共 24 层
return x3
没开 MemoryReusePass 时:
- 每层的中间激活都占显存(直到层输出被消费)
- 24 层 × 每层 2 个激活 × 每个激活 2MB = 96MB 显存峰值
开了 MemoryReusePass 后:
x1在 Layer 2 开始时就可以释放(复用给x2)x2在 Layer 3 开始时就可以释放(复用给x3)- 显存峰值:8MB(只有 4 层的中间激活)
开关方法:
python
# enable_memory_reuse.py
import os
# 开启内存复用(默认开启,但可以调策略)
os.environ['GE_MEMORY_STRATEGY'] = 'aggressive' # conservative / balanced / aggressive
# 内存复用配置
memory_config = {
"reuse_policy": "lifetime", # lifetime=按生命周期复用,size=按大小复用
"alignment": 32, # 显存对齐(32字节)
"max_reuse_ratio": 0.8 # 最大复用比例(0.8=80% 可以复用)
}
# 保存配置
import json
with open("memory_config.json", "w") as f:
json.dump(memory_config, f, indent=2)
# 转 OM 时指定内存配置
os.system(f"""
atc --model=transformer.onnx \
--framework=5 \
--output=transformer_mem \
--op_precision_mode=force_fp16 \
--memory_config_file=memory_config.json
""")
性能数据(GPT-2 1.5B):
| 配置 | 显存峰值 (MB) | 最大 Batch Size |
|---|---|---|
| 基线(无复用) | 4862 | 8 |
| + MemoryReusePass | 3157 | 13 |
节省显存 :4862 → 3157 = 35.1% ↓
Batch Size 提升 :8 → 13 = 62.5% ↑
关键 Pass 4:L2 融合(L2FusionPass)
作用:把 L2 缓存友好的算子融合在一起,减少 HBM 读写。
背景:
- NPU 的 L2 缓存只有 32MB(Ascend 910B)
- 如果算子输入输出超过 32MB,就得频繁读写 HBM(带宽只有 L2 的 1/10)
- L2FusionPass 把"计算密度高、内存占用小"的算子融合,尽量在 L2 里完成
典型案例:
python
# ResNet-50 的 Bottleneck(卷积 + BN + Relu)
import torch
import torch.nn as nn
class Bottleneck(nn.Module):
def forward(self, x):
# Conv1x1 + BN + Relu
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out) # L2 友好(输出小)
# Conv3x3 + BN + Relu
out = self.conv2(out)
out = self.bn2(out)
out = self.relu(out) # L2 友好
# Conv1x1 + BN(无 Relu,输出大)
out = self.conv3(out)
out = self.bn3(out) # L2 不友好(输出可能超过 32MB)
# 残差连接
out += self.downsample(x)
out = self.relu(out)
return out
没开 L2FusionPass 时:
conv3 + bn3的输出要写回 HBM(如果超过 L2 容量)- 下次读的时候再从 HBM 加载 → 带宽瓶颈
开了 L2FusionPass 后:
- 把
conv3 + bn3 + residual_add + relu融合成一个算子 - 中间结果全在 L2 里,不写回 HBM → 带宽节省
开关方法:
python
# enable_l2_fusion.py
import os
# 开启 L2 融合(默认关闭,因为可能增加编译时间)
os.environ['GE_L2_FUSION'] = '1' # 0=关闭,1=开启
# L2 融合配置
l2_config = {
"l2_size": 32, # L2 缓存大小(MB)
"fusion_threshold": 0.8, # 融合阈值(0.8=80% 的 L2 命中率才融合)
"blacklist": [ # 黑名单(不融合的算子)
"Softmax",
"LayerNorm"
]
}
# 保存配置
import json
with open("l2_config.json", "w") as f:
json.dump(l2_config, f, indent=2)
# 转 OM 时指定 L2 配置
os.system(f"""
atc --model=resnet50.onnx \
--framework=5 \
--output=resnet50_l2 \
--op_precision_mode=force_fp16 \
--l2_fusion_config=l2_config.json
""")
性能数据(ResNet-50):
| 配置 | 推理延迟 (ms) | HBM 读写 (GB/s) |
|---|---|---|
| 基线(无 L2 融合) | 8.9 | 112 |
| + L2FusionPass | 6.7 | 79 |
加速比 :8.9 → 6.7 = 24.7% ↑
带宽节省 :112 → 79 = 29.5% ↓
总结:真正影响性能的 Pass
| Pass | 延迟加速 | 显存节省 | 吞吐提升 | 推荐等级 |
|---|---|---|---|---|
| ConstantFoldingPass | 18.7% ↑ | 0% | 23.0% ↑ | ⭐⭐⭐⭐⭐ |
| OpFusionPass | 23.3% ↑ | 0% | 30.4% ↑ | ⭐⭐⭐⭐⭐ |
| MemoryReusePass | 0% | 35.1% ↓ | 62.5% ↑ (Batch) | ⭐⭐⭐⭐ |
| L2FusionPass | 24.7% ↑ | 0% | 32.8% ↑ | ⭐⭐⭐⭐ |
| DeadCodeEliminationPass | 2.1% ↑ | 1.2% ↓ | 2.5% ↑ | ⭐⭐ |
| CommonSubexpressionEliminationPass | 1.8% ↑ | 0% | 2.2% ↑ | ⭐⭐ |
关键点:
- 必开:ConstantFoldingPass、OpFusionPass、MemoryReusePass、L2FusionPass
- 选开:DeadCodeEliminationPass(小模型收益低)、CommonSubexpressionEliminationPass(大模型收益低)
- 不开:其他 40 个 Pass(对推理性能几乎没影响)
延伸阅读
如果你对 GE 图优化感兴趣,推荐阅读以下资料:
- GE 图优化源码 :
ge/ge_graph/optimize/目录下有所有 Pass 的实现代码,注释很详细。 - CANN 性能调优指南:昇腾社区官网有份《CANN 性能调优指南》,里面专门有一章讲 GE Pass 的开关策略,建议通读。
- HCCL 通信优化:MC2 算子依赖 HCCL 做全到全通信,了解 HCCL 的底层实现有助于理解为什么 MC2 比原生 AllGather 快。
遇到推理性能问题,先看 GE 的 Pass 有没有全部开启,尤其是算子融合和内存复用。