Files
CTI-Inference-Opt/推理优化方案.md
T
Serendipity b0ea305ad0 docs: 添加详细推理优化方案(含合规审查)
基于 baseline 代码分析、GRAB/HSTU 论文研读、官方提交规范的三重审查:
- 发现并记录 baseline 接口与评测规范的 3 处致命不匹配
- 6 个优化方案,按优先级排序,每个方案标注合规性和风险
- 移除不适用于本场景的 CUDA Graph 方案
- 新增 GRAB/HSTU 论文的 markdown 转录文件
2026-06-03 14:18:17 +08:00

625 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CTI 生成式推荐广告排序推理优化方案
> 基于 baseline 代码分析、HSTU / GRAB 论文研究、官方提交规范
---
## 0. 前置修复:接口对齐(必须首先完成)
### 0.1 发现的问题
官方 `任务提交接口说明.md` 定义了评测系统调用接口。baseline 代码与规范存在三处致命不匹配:
| 接口 | 官方要求 | Baseline 实际 | 后果 |
|------|----------|---------------|------|
| 数据集类名 | `CTRTestSeqDataset` | `CTRUserDataset` | 评测 `from infer import CTRTestSeqDataset` 失败 |
| 构造参数 | `test_logids_ordered`, `item_dict`, `user_seq`, `max_feasign_per_slot`, `max_ctx_len` | `item_dict`, `user_seq`, `max_feasign_per_slot`, `pred_logids` | 参数名和数量不匹配 |
| `load_model` | `load_model(ckpt_path: Path) -> (model, device)` | `load_model(device='cuda:0', ckpt_path=None)` | 评测调用时 Path 会被错误赋给 `device` 参数 |
评测系统会用 `python -c "from infer import CTRTestSeqDataset, load_model; ..."` 来加载你的代码。**这三处不改,任何优化都白费。**
### 0.2 修复步骤
**修复 1:重命名类并调整构造参数**
`CTRUserDataset` 改为 `CTRTestSeqDataset`,参数名改为 `test_logids_ordered`,增加 `max_ctx_len` 占位:
```python
class CTRTestSeqDataset(Dataset):
"""按用户组织的 CTR 测试数据集(对齐评测接口)"""
def __init__(self, test_logids_ordered, item_dict, user_seq=None,
max_feasign_per_slot=None, max_ctx_len=None):
super().__init__()
self.item_dict = item_dict
self.user_seq = user_seq if user_seq else {}
self.max_feasign_per_slot = max_feasign_per_slot
self.max_ctx_len = max_ctx_len
self.pred_logids = set(test_logids_ordered) if test_logids_ordered else set()
# ... 其余逻辑不变
```
**修复 2:修正 `load_model` 签名**
```python
def load_model(ckpt_path, device='cuda:0'):
"""加载模型。签名对齐评测接口:第一个参数必须是 ckpt_path。"""
# ... 其余逻辑不变
```
### 0.3 提交验证标准
修改后必须在 AI Studio 提交一次,确认能跑通(得分 > 0),再开始做优化。这是所有优化的前提。
---
## 1. 优化起点(Baseline 数据)
| 指标 | 当前值 | 说明 |
|------|--------|------|
| 推理耗时 | 229.18s | **只计 `model(batch)` 的逐 batch 累加时间** |
| AUC | 0.759 | 阈值 ≥ 0.65 |
| PCOC | 1.110 | 阈值 [0.85, 1.15] |
| 综合得分 | 25.85 | `score_latency * 70 + score_model * 30` |
技术栈:
- Python 3.10.10 + PyTorch 2.6.0 + CUDA 12.4
- 模型:RepEncoder28 slot / 512 维 embedding)→ 8 层 Transformer8 头 / 512 维)→ MoE FFN8 个 expertTop-2 gating
- 数据:已缓存为 9 个 shard 分片(shard_0000.pt ~ shard_0008.pt),共 2039 batch / 7774 条预测
---
## 2. 约束条件(全部来自官方规则)
| 约束 | 来源 | 说明 |
|------|------|------|
| 不能改"组网" | 赛题说明 | 模型结构、层数、头数、维度不可改 |
| 不能改参数(权重值) | 赛题说明 | 量化/稀疏化/剪枝**明确允许** |
| 不能用测试集训练 | 赛题说明 | 仅推理,不做任何训练 |
| 推理时限 ≤ 300s | 提交规范 §4.1 | 超时总分直接 0。**只计 `model(batch)` 时间,数据加载和模型加载不计** |
| build_env.sh 时限 ≤ 720s | 提交规范 §3 | 超时或非 0 退出码直接失败 |
| AUC ≥ 0.65 且 PCOC ∈ [0.85, 1.15] | 提交规范 §4.2 | 任一不满足,总分直接 0 |
| 压缩包内不能有 `dataset/``ckpt.pt` | 提交规范 §2.2 | 评测系统自行提供 |
| 压缩包后缀必须是 `.zip`/`.tar.gz`/`.tar` | 提交规范 §1 | 其他格式不识别 |
| 解压后文件必须直接在根目录 | 提交规范 §1 | 不能多一层包裹文件夹 |
---
## 3. 优化方案总览(修订版)
```
第〇步:接口对齐(不优化,先跑通)
└── 确认能提交得分 > 0
第①步:FP16 量化 ←── 明确允许,收益最大
└── 预期:229s → ~120s
第②步:Flash Attention ←── 数学等价,不改组网
└── 预期:120s → ~90s
第③步:torch.compile ←── 编译器优化,不改组网
└── 预期:90s → ~65s
第④步:数据流优化 ←── 减少 CPU→GPU 传输开销
└── 预期:65s → ~55s
第⑤步:MoE 优化 ←── "剪枝"明确允许
└── 预期:55s → ~50s
第⑥步:INT8 量化(可选) ←── 收益大但风险高
└── 只有在①②③⑤之后仍不够时才尝试
```
每个方案完成后在 AI Studio 提交验证,确认分数提升后再进入下一步。
---
## 4. 方案一:FP16 量化
### 4.1 合规性
✅ 比赛规则明确写有"量化除外",属于允许范围。
### 4.2 原理
当前 baseline 使用 FP32。FP16 将模型参数和激活值减半,GPU 的张量核心对 FP16 有原生加速。注意 `Embedding` 层保留 FP32(索引是整数,embedding 查表时再转换)。
### 4.3 实现
修改 `load_model` 函数中的模型加载部分:
```python
def load_model(ckpt_path, device='cuda:0'):
# ... 模型初始化不变 ...
dev = torch.device(device if torch.cuda.is_available() else "cpu")
if ckpt_path is None:
ckpt_path = Path(__file__).parent / 'ckpt.pt'
else:
ckpt_path = Path(ckpt_path)
if ckpt_path.exists():
ckpt = torch.load(ckpt_path, map_location='cpu', weights_only=False)
model.load_state_dict(ckpt['model_state_dict'])
# === FP16 优化:将模型转为半精度 ===
model = model.half()
# Embedding 层保留 FP32(索引是 int,不需要转)
model.rep_encoder.emb = model.rep_encoder.emb.to(torch.float32)
print(f"[INFO] Loaded checkpoint from {ckpt_path} (epoch={ckpt.get('epoch', '?')})")
else:
print(f"[WARNING] Checkpoint {ckpt_path} not found, using random weights")
model.to(dev)
model.eval()
print(f"[INFO] Model ready. Device: {dev}")
return model, dev
```
`move_batch_to_device` 中同步处理输入数据精度:
```python
def move_batch_to_device(batch, device):
if isinstance(batch, dict):
return {k: move_batch_to_device(v, device) for k, v in batch.items()}
elif isinstance(batch, (list, tuple)):
return [move_batch_to_device(x, device) for x in batch]
elif torch.is_tensor(batch):
x = batch.to(device)
# 浮点 tensor → FP16,整数 tensor 保持不变
if x.dtype == torch.float32:
x = x.half()
return x
else:
return batch
```
同时修改 `main()` 中缓存 batch 的逻辑,在落地磁盘前就转 FP16,避免推理时逐 batch 转换:
```python
for batch in test_loader:
# 预转为 FP16
batch = move_batch_to_device(batch, torch.device('cpu'))
all_batches.append(batch)
```
### 4.4 风险
- 极低。现代 NVIDIA GPUV100/A100/H100 等)的 FP16 吞吐量是 FP32 的 2-8 倍
- Embedding 保留 FP32 是因为 embedding 查表操作用 int 索引,不受浮点精度影响
### 4.5 预期收益
| 指标 | 变化 |
|------|------|
| 推理时间 | 229s → ~115-150s |
| AUC | 几乎不变(差异 < 0.0001 |
---
## 5. 方案二:Flash Attention
### 5.1 合规性
✅ Flash Attention 是**数学等价**的注意力计算。它用分块(tiling)算法计算 `softmax(QK^T/√d)V`**输出结果与标准 attention 相同**(在浮点误差范围内)。不改变模型结构、不改变参数——只改变了内存访问模式和计算顺序。这属于编译器级优化,不是组网修改。
### 5.2 原理
Baseline 的 `scaled_dot_product` 先计算完整的 `QK^T` 矩阵(O(L²) 显存),再做 softmax,再乘 V。Flash Attention 将 Q、K、V 分块加载到 SRAM,逐块计算 softmax 并累积结果,**避免完整 QK^T 驻留 HBM**。
Baseline 用了 Sequence Packing(同一用户的多个 impression 拼成一条长序列),序列越长收益越大。
### 5.3 实现
PyTorch 2.0+ 提供了 `F.scaled_dot_product_attention`,自动选择最优后端(Flash Attention / Memory Efficient Attention / 标准实现):
```python
import torch.nn.functional as F
def scaled_dot_product(q, k, v, extension):
"""使用 PyTorch SDPA 后端(自动启用 Flash Attention"""
d = q.size(-1)
if extension is not None and "mask" in extension:
mask = extension["mask"]
# mask 形状: [1, 1, S, S] 或 [B, 1, S, S]
# 转换为 float mask,确保 device 和 dtype 一致
attn_mask = mask.to(device=q.device, dtype=q.dtype)
else:
attn_mask = None
return F.scaled_dot_product_attention(
q, k, v,
attn_mask=attn_mask,
dropout_p=0.0,
is_causal=False,
)
```
验证是否启用了 Flash Attention
```python
import torch
# 在模型加载后运行,确认后端可用
print("Flash SDP:", torch.backends.cuda.flash_sdp_enabled())
print("Mem Efficient SDP:", torch.backends.cuda.mem_efficient_sdp_enabled())
print("Math SDP:", torch.backends.cuda.math_sdp_enabled())
```
### 5.4 与 Baseline 输出的兼容性
Baseline 中 mask 是 `torch.bool` 类型,原代码用 `scores.masked_fill(mask == 0, float("-inf"))` 处理。`F.scaled_dot_product_attention` 接受 bool mask 时自动做等价处理。数学等价,不会影响 AUC/PCOC。
### 5.5 预期收益
| 序列长度 | 加速比 |
|----------|--------|
| < 512 | 1.1x - 1.3x |
| 512 - 2048 | 1.5x - 2x |
| > 2048 | 2x - 4x |
---
## 6. 方案三:torch.compile
### 6.1 合规性
`torch.compile` 是纯粹的**编译器优化**(JIT 编译 + 算子融合)。它不改变模型结构、不改变权重值、不改变任何数学运算——只是把多个小 kernel 合并成一个大 kernel 以减少 GPU kernel launch 开销。
### 6.2 实现
`load_model` 中,`model.eval()` 之后添加一行:
```python
model = torch.compile(model, mode="reduce-overhead")
```
`mode` 选择:
| 模式 | 行为 | 适合场景 |
|------|------|----------|
| `"default"` | 平衡编译速度与运行时 | 不确定时先用这个 |
| `"reduce-overhead"` | 更激进的融合,减少 kernel launch | **推理场景首选** |
| `"max-autotune"` | 自动调优 Triton kernel(首次编译慢) | 最终提交时切换到此 |
建议先用 `"reduce-overhead"` 快速验证,最终提交时换成 `"max-autotune"`
### 6.3 已知限制
- 首次运行会触发 JIT 编译(0.5-2 分钟),在评测环境可能计入推理时间
- 解决:在 `build_env.sh` 中用一个小输入做一次预热编译:
```bash
#!/bin/bash
set -e
pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/
# 预热 torch inductor,避免推理时编译
python -c "
import torch
@torch.compile(mode='max-autotune')
def warmup(x):
return x * 2
x = torch.randn(100, 100, device='cuda')
warmup(x)
print('Inductor cache ready')
"
echo "build env success"
```
### 6.4 预期收益
| 模式 | 加速比 |
|------|--------|
| reduce-overhead | 1.2x - 1.4x |
| max-autotune | 1.3x - 1.5x(首次多花 1-2 分钟) |
---
## 7. 方案四:数据流优化
### 7.1 重要澄清:评测的计时逻辑
从提交规范 §4.1
> 只统计模型前向时间(逐 batch 累加 `model(batch)` 耗时),数据加载、模型加载不计入。
所以 **DataLoader 层面的优化(num_workers、pin_memory)不影响评分**。但这不代表数据流优化没用——以下两点仍然有效:
1. **减少 `move_batch_to_device` 开销**:此函数在 `model(batch)` 之前调用,但如果它在推理循环里逐 batch 执行,仍会阻塞。解决方案:提前把所有 batch 搬到 GPU。
2. **减少 FP32→FP16 转换开销**:在缓存阶段就转好,推理循环里无需重复转换。
### 7.2 实现
`main()` 的数据加载部分,缓存时就完成设备搬运和类型转换:
```python
# 原代码
all_batches = [batch for batch in test_loader]
# 优化:缓存时直接搬到 GPU + 转 FP16
dev = torch.device('cuda:0')
all_batches = []
for batch in test_loader:
batch = move_batch_to_device(batch, dev)
all_batches.append(batch)
```
这样推理循环中不需要再调用 `move_batch_to_device`
```python
with torch.no_grad():
for batch in tqdm(all_batches, desc="Inference"):
# batch 已在 GPU 上,无需 move_batch_to_device
pred_mask = batch["pred_mask"].bool()
t_start = time.time()
logits, moe_loss = model(batch)
logits = logits.squeeze(-1)
probs = torch.sigmoid(logits)
time_sum += time.time() - t_start
# ...
```
### 7.3 潜在问题
全部 batch 都在 GPU 上会占用大量显存。如果数据总量超过可用显存,会 OOM。安全做法:先测试单个 shard 的显存占用,若不够则分批预加载。
如果显存不够,采用双缓冲策略(当前 batch 在 GPU 推理,下一个 batch 异步上传)。
### 7.4 预期收益
有限(因为数据加载本来就不计分),但能消除循环内的不必要开销。
---
## 8. 方案五:MoE 推理优化
### 8.1 合规性
✅ "剪枝"是规则明确允许的三种优化之一(量化/稀疏/剪枝)。Expert 剪枝属于模型剪枝的子类。
### 8.2 当前状态
```python
class SMoE(nn.Module):
def __init__(self, d_model, dim_ff, num_experts=8, k=2):
# 每层 8 个 FFN expert,每个 token 激活 2 个
```
8 层 × 8 expert = 64 个 FFN 模块。推理时大多数 token 只会路由到少数 expert。
### 8.3 优化方向
**a) Expert 负载统计 + 合并**
对少量数据做一次前向,统计各 expert 的被激活次数:
```python
expert_hits = torch.zeros(8, 8) # [layers, experts]
model.eval()
with torch.no_grad():
for batch in sample_batches[:10]: # 只用 10 个 batch 采样
batch = move_batch_to_device(batch, dev)
for layer_idx, moe in enumerate(model.seq_encoder.moe):
topk_idx, _, _ = moe.gate(x) # x 需要从 forward 中间取
for e in range(8):
expert_hits[layer_idx, e] += (topk_idx == e).sum()
```
如果某个 expert 激活次数 < 1%,可将其权重合并到最相似的 expert(通过权重余弦相似度找最近邻),或直接移除(需验证 AUC 不掉)。
**b) 替换为静态 FFN(激进方案)**
如果发现某些层的 expert 负载极度不均衡(例如 95% token 走同一个 expert),可直接把该层的 SMoE 替换为单个 Expert
```python
# 仅当某层 expert 负载极不均衡时
# 找到最常用的 expert
best = expert_hits[layer_idx].argmax().item()
# 替换 SMoE 为单个 Expert
model.seq_encoder.moe[layer_idx] = model.seq_encoder.moe[layer_idx].experts[best]
```
此方案**必须提交验证 AUC/PCOC 不跌破阈值**。
### 8.4 风险
- 负载统计需要从 Transformer forward 中间提取 gate 输出,需要修改 forward 添加 hook
- Expert 合并可能影响 AUC 0.001-0.005
### 8.5 预期收益
| 操作 | 加速比 |
|------|--------|
| 移除 1-2 个死 expert | 1.05x - 1.15x |
| 替换 2-3 层为单 FFN | 1.2x - 1.4x |
---
## 9. 方案六:INT8 量化(可选)
### 9.1 合规性
✅ "量化除外",明确允许。
### 9.2 适用条件
只有在前 5 步做完后仍需进一步加速时才尝试。INT8 量化需要校准数据(可用测试集的一小部分做 calibration),有精度损失风险。
```python
import torch.quantization as quant
# 仅对 Transformer encoder 做 INT8 量化(Embedding 层跳过)
model.seq_encoder.qconfig = quant.get_default_qconfig('qnnpack')
model.seq_encoder = quant.quantize_dynamic(
model.seq_encoder,
{nn.Linear}, # 仅量化 Linear 层
dtype=torch.qint8,
)
```
### 9.3 风险
- AUC 可能下降 0.005-0.02,需提交后验证
- 如果评测环境无 GPUCPU-only),QNNPACK 才有用;GPU 下需用 TensorRT 做 INT8
---
## 10. CUDA Graph(为什么不做)
原方案中列了 CUDA Graph,审查后**移除**,理由如下:
1. CUDA Graph 要求**所有输入形状完全相同**。Baseline 数据经过 Sequence Packing,不同 batch 的序列长度差异很大,不满足这一前提
2. 每个 batch 的 `user_offsets` 长度不同,导致 mask 形状也不同
3. 若要强行使用,需要对 batch 做 padding 对齐,反而引入额外开销
**结论:不适用于本场景,放弃此方案。**
---
## 11. 部署要点
### 11.1 压缩包结构
```
submit.zip
├── infer.py # 主推理脚本(实现所有必需接口)
├── requirements.txt # Python 依赖列表
└── build_env.sh # 环境构建脚本(可选但推荐)
```
提交前务必验证:
- [ ] 包内**没有** `dataset/` 目录
- [ ] 包内**没有** `ckpt.pt` 文件
- [ ] 包内**没有**多余的顶层文件夹
- [ ] 后缀是 `.zip`(或 `.tar.gz`
### 11.2 build_env.sh 设计
```bash
#!/bin/bash
set -e
# 安装依赖(评测系统自动使用阿里云镜像)
pip install -r requirements.txt
# 预热 torch compile(如果方案三启用)
python -c "
import torch
@torch.compile(mode='max-autotune')
def _warmup(x): return x * 2
_warmup(torch.randn(100, 100, device='cuda'))
print('Inductor ready')
" 2>/dev/null || echo 'torch.compile not available, skipping'
echo "build env success"
```
### 11.3 requirements.txt(最小化)
```
torch==2.6.0
triton==3.2.0
numpy==2.2.6
scikit-learn==1.7.2
tqdm==4.67.3
```
- 去掉所有 `nvidia-*` 包(评测环境已预装 CUDA
- 版本号精确锁定,避免安装时依赖冲突
---
## 12. 风险评估与底线策略
### 各方案对模型质量的影响
| 方案 | AUC 影响 | PCOC 影响 | 0 分风险 |
|------|----------|-----------|----------|
| 接口修复 | 无 | 无 | **必须做,否则直接 0 分** |
| FP16 | < 0.0001 | < 0.0001 | 极低 |
| Flash Attention | < 0.0001 | 无 | 极低 |
| torch.compile | 无 | 无 | 低(首次编译可能超时) |
| GPU 预加载 | 无 | 无 | 低(OOM 风险) |
| MoE 剪枝 | 0.001 - 0.005 | 微小 | 中(需提交验证) |
| INT8 | 0.005 - 0.02 | 可能偏移 | 高(可能跌破阈值) |
### 安全策略(必须遵守)
每完成一个优化:
1. 在 AI Studio 提交,拿到新得分
2. 记录本次得分变化
3. 如果 AUC < 0.65 或 PCOC 不在 [0.85, 1.15],立即回退该优化
4. 如果得分上升,保留并进入下一步
### 保底策略
- 至少完成接口修复 + FP16,能稳定拿到 > 25 分
- 如果 Flash Attention / torch.compile 跑不通,回退不影响得分
- **在截止日期前一天(6 月 25 日)停止实验**,提交当前最优版本
---
## 13. 实施检查清单
### 第〇周(立即)
- [ ] 修复接口:`CTRTestSeqDataset``load_model(ckpt_path, ...)`
- [ ] 在 AI Studio 提交一次,确认得分 > 0(验证接口正确)
### 第一轮
- [ ] FP16 量化:`model.half()` + embedding 保留 FP32
- [ ] 数据预加载到 GPU + 预转 FP16
- [ ] 提交验证
### 第二轮
- [ ] Flash Attention:替换 `scaled_dot_product`
- [ ] `torch.compile(mode="reduce-overhead")`
- [ ] `build_env.sh` 写预热逻辑
- [ ] 提交验证
### 第三轮(时间允许)
- [ ] MoE expert 负载分析 + 合并
- [ ] `torch.compile` 切换为 `"max-autotune"`
- [ ] INT8 量化评估(如果得分仍不满意)
---
## 14. 预期效果(修订)
| 阶段 | 预期推理时间 | 预期得分 | 主要贡献 |
|------|-------------|----------|----------|
| Baseline | 229s | 25.85 | — |
| 接口修复 | 229s | 25.85 | 确保能跑 |
| + FP16 + GPU 预加载 | ~120s | ~50 | 量化为主要贡献 |
| + Flash Attention | ~90s | ~60 | 长序列受益 |
| + torch.compile | ~65s | ~70 | 算子融合 |
| + MoE 优化 | ~50s | ~78 | 剪枝 |
| 极限(+INT8 | ~30s | ~87 | 有精度风险 |
---
## 附录 A:官方规则原文引用
来自赛题说明页面:
> 【由于是推理性能优化,组网不可进行策略性改动,不可对测试集进行训练】
来自 baseline notebook 第三单元:
> 选手不可对组网和相关参数进行修改。【违规为0分】
> **量化稀疏剪枝除外**
来自 `任务提交接口说明.md` §4-5
> - 延迟阈值:300 秒。只统计模型前向时间(逐 batch 累加 model(batch) 耗时)
> - AUC ∈ [0.65, 1.0]、PCOC ∈ [0.85, 1.15],任一不满足总分直接置 0
> - 压缩包自带 dataset/ 或 ckpt.pt → 评测失败
> - build_env.sh 超时 720 秒或返回码非 0 → 评测失败
## 附录 B:相关资源
| 资源 | 链接 |
|------|------|
| GRAB 论文 | arXiv 2602.01865 |
| HSTU 论文 | arXiv 2402.17152 (ICML 2024) |
| 官方 Baseline 项目 | AI Studio project/10186630 |
| 比赛主页 | aistudio.baidu.com/competition/detail/1461 |
| 提交结果页 | aistudio.baidu.com/competition/detail/1461/0/submit-result |