# 显存还有余量为什么还OOM?海光DCU推理显存账本与静默错误四层验证实战
> 在DCU上部署大模型,最常见的两个困惑:一是"显存明明还有20GB,为什么报OOM?",二是"模型能跑、不报错,为什么生成结果偶尔不对劲?"。本文从DCU显存架构出发,拆解推理服务的完整显存账本,分析OOM的真实成因,并给出一套从算子到业务的四层静默错误验证方法论。
## 一、引言:两个被反复问到的问题
在海光DCU技术社区里,出现频率最高的两类问题是:
**问题一:显存余量与OOM的悖论**
> "我的K100有96GB显存,7B模型BF16才14GB,KV Cache算上也不到40GB,为什么并发一高就OOM?"
**问题二:模型能跑≠跑对**
> "模型加载成功、推理不报错、loss也在下降,但生成的文本偶尔出现语义偏移,换CUDA卡跑同样的输入结果不一样,这是怎么回事?"
这两个问题看似无关,实则指向同一个根源:**对DCU的显存管理机制和数值正确性缺乏系统性理解。** 本文将逐一拆解。
## 二、DCU显存架构全景:不只是一块HBM
理解OOM之前,必须先理解DCU的存储层次。DCU的存储不是"一块大显存",而是一个有严格层级的体系:
```
寄存器 (0周期) → L1 Vector Cache (≈30周期) → L2 Cache (≈200周期) → HBM (≈400周期)
```
### 2.1 各层级的关键参数
以Z100L(gfx906)为例:
| 存储层级 | 容量 | 延迟 | 管理方式 | 关键特性 |
| --- | --- | --- | --- | --- |
| VGPR寄存器 | 256×32bit/SIMD = 1KB/SIMD | 0周期 | 编译器自动分配 | 不会miss,但数量有限,决定Occupancy |
| L1 Vector Cache | 16KB/CU | ≈30周期 | 硬件自动管理 | 仅合并访问有效,跨步访问完全失效 |
| LDS共享内存 | 64KB/CU | ≈20周期 | 程序员显式管理 | 类似CUDA的`__shared__`,32个bank |
| L2 Cache | 4MB/芯片 | ≈200周期 | 硬件自动管理 | 所有CU共享,命中率决定HBM压力 |
| HBM2显存 | 32~96GB/卡 | ≈400周期 | 运行时分配器管理 | 1024-bit总线,带宽约1TB/s |
### 2.2 HBM与普通DDR的本质区别
很多人把HBM当成"更快的DDR",这是误解:
- **DDR**:水平插在主板上的DIMM,64-bit总线,带宽约50GB/s,容量可扩展
- **HBM**:通过硅中介层(silicon interposer)与GPU die垂直堆叠,1024-bit总线,带宽约1TB/s,**宽16倍、快20倍**,但容量固定、成本极高
这意味着:**HBM的瓶颈从来不是容量,而是带宽和分配效率。** 96GB HBM如果被碎片化切割,实际可用的连续大块可能远小于标称值。
## 三、推理显存账本:你的显存到底花在哪了
部署一个大模型推理服务时,显存占用远不止"模型权重"这一项。完整的显存账本包括四大类:
### 3.1 权重(Weights)
这是最直观的部分,但也有容易忽略的细节:
| 精度 | 7B模型权重 | 13B模型权重 | 70B模型权重 |
| --- | --- | --- | --- |
| FP32 | 28GB | 52GB | 280GB |
| BF16/FP16 | 14GB | 26GB | 140GB |
| INT8 | 7GB | 13GB | 70GB |
| INT4(AWQ/GPTQ) | ~3.5GB | ~6.5GB | ~35GB |
**注意点**:
- 权重加载时需要**临时缓冲区**,实际峰值占用 = 权重大小 × 1.2~1.5(取决于加载方式)
- 使用`device_map="auto"`时,显存中可能同时存在CPU→GPU传输的中间副本
- 量化模型的反量化权重在推理时可能需要额外显存存储中间结果
### 3.2 KV Cache(推理显存的大头)
KV Cache是自回归推理中显存增长的核心来源。计算公式:
```
KV Cache显存 = 2 × num_layers × num_kv_heads × head_dim × seq_len × batch_size × bytes_per_element
```
以Qwen2-7B为例(28层、28个KV头、head_dim=128):
| 配置 | 单请求4K上下文 | 单请求8K上下文 | 32并发×4K |
| --- | --- | --- | --- |
| BF16 KV Cache | 448MB | 896MB | 14.3GB |
| FP8 KV Cache | 224MB | 448MB | 7.2GB |
**关键洞察**:KV Cache随**序列长度和并发数线性增长**。当你设置`max-model-len=32768`且`max-num-seqs=128`时,即使当前请求很短,vLLM也会**预分配**足够的KV Cache block来支撑最坏情况,这就是"显存还有余量但OOM"的常见原因。
### 3.3 激活值与中间张量
推理过程中的激活值占用往往被低估:
- **Prefill阶段**:需要存储完整的Attention中间矩阵,长序列下可能达到数GB
- **Decode阶段**:激活值较小,但每个算子的输入输出都需要临时显存
- **算子融合缺失**:未融合的逐元素算子会产生大量中间张量,每个都要写回HBM再读出
### 3.4 显存碎片与运行时开销
这是最隐蔽的显存消耗者:
- **PyTorch缓存分配器**:为了避免频繁向驱动申请显存,会缓存已释放的显存块。这些缓存块在`nvidia-smi`/`rocm-smi`中显示为"已占用",但实际可被复用
- **碎片问题**:频繁的申请/释放会产生大量小碎片,当需要一个大块连续显存时,即使总余量足够也会OOM
- **驱动预留**:DCU驱动本身会预留约5~10%的显存,这部分用户不可见
## 四、OOM的真实成因分析
基于上面的显存账本,"显存还有余量但OOM"通常有以下几类原因:
### 4.1 原因一:KV Cache预分配超出预期
vLLM的PagedAttention机制会按`max-model-len`和`max-num-seqs`预分配KV Cache block。设置不当会导致:
```bash
# 危险配置:32K上下文 + 128并发
vllm serve ... --max-model-len 32768 --max-num-seqs 128
# KV Cache预分配可能达到 2×28×28×128×32768×128×2 = ~16GB(BF16)
```
**解决方案**:
```bash
# 1. 合理设置gpu_memory_utilization,预留缓冲
--gpu-memory-utilization 0.85
# 2. 开启chunked prefill,降低长prompt的峰值压力
--enable-chunked-prefill
# 3. 使用FP8 KV Cache减半占用
--kv-cache-dtype fp8
# 4. 调整block_size提升内存利用率
--block-size 16
```
### 4.2 原因二:显存碎片导致大块分配失败
高并发下频繁的KV Cache block申请/释放会产生碎片。当一个新请求需要较大的连续block时,即使总余量足够也会失败。
**解决方案**:
```bash
# 优化PyTorch缓存分配器行为
export PYTORCH_HIP_ALLOC_CONF=max_split_size_mb:128,garbage_collection_threshold:0.8
# 固定显存分配阈值,减少运行时碎片
# 避免频繁的expand/cycle操作
```
### 4.3 原因三:模型加载峰值 > 稳态占用
模型从CPU加载到GPU时,需要临时缓冲区存储反序列化的数据。对于大模型,这个峰值可能比稳态高20~50%。
**解决方案**:
- 使用`safetensors`格式逐张量加载,避免整模型一次性读入内存
- 加载完成后显式调用`torch.cuda.empty_cache()`释放临时缓冲
- 使用`low_cpu_mem_usage=True`参数
### 4.4 原因四:NUMA跨节点内存访问异常
双路服务器中,如果DCU绑定在NUMA节点0,但进程运行在节点1的CPU上,跨节点的DMA传输可能触发异常的显存映射,表现为"偶发OOM"。
**解决方案**:
```bash
# 查询DCU的NUMA归属
rocm-smi --showtopo
# 绑定到对应节点
numactl --cpunodebind=0 --membind=0 python serve.py
```
## 五、静默错误:模型能跑≠跑对
比OOM更危险的是**静默错误(Silent Error)**——模型不报错、不崩溃,但生成结果存在数值偏差。在DCU上,这类问题尤其需要警惕。
### 5.1 静默错误的常见来源
| 来源 | 表现 | 触发条件 |
| --- | --- | --- |
| 自定义算子数值错误 | 个别Token偏差,长文本累积成语义偏移 | 融合算子、定制GEMV等手写kernel |
| 访存未合并导致的部分数据错误 | 输出中出现NaN或异常值,但概率低 | 跨步访问、索引计算错误 |
| BF16精度累积误差 | 长序列生成质量下降 | 超过模型原生上下文长度 |
| 量化反量化误差 | 特定句式下输出异常 | AWQ/GPTQ量化的分组边界 |
| 多卡同步错误 | 张量并行结果不一致 | TP>1时的all-reduce时序问题 |
| HIP Graph重放错误 | 第二次推理结果与第一次不同 | CUDA/HIP Graph捕获不完整 |
### 5.2 为什么DCU上静默错误更难发现
1. **HIP与CUDA的细微差异**:虽然语法99%兼容,但wave64 vs warp32、共享内存bank数、原子操作语义等差异,可能导致从CUDA移植的代码在DCU上产生不同的数值行为
2. **BF16原生支持差异**:不同硬件对BF16的舍入模式、融合乘加(FMA)精度处理可能不同
3. **缺少成熟的验证工具链**:NVIDIA生态有丰富的正确性验证工具,DCU生态相对薄弱,很多问题需要手动排查
## 六、四层验证方法论:从算子到业务
针对静默错误,我们建立了一套四层验证体系,每一层都不可跳过。
### 6.1 第一层:算子级验证
**目标**:确保每个自定义算子的输出与参考实现一致。
```python
import torch
def verify_operator(custom_op, ref_op, input_shapes, dtypes, atol=1e-5, rtol=1e-4):
"""逐算子数值验证"""
for shape in input_shapes:
for dtype in dtypes:
# 固定种子,生成测试输入
torch.manual_seed(42)
x = torch.randn(shape, dtype=dtype, device="cuda")
# 参考实现(PyTorch原生或CUDA版本)
ref = ref_op(x).float().cpu()
# 自定义实现
actual = custom_op(x).float().cpu()
# 多层级检查
has_nan = torch.isnan(actual).any().item()
has_inf = torch.isinf(actual).any().item()
max_abs_err = (ref - actual).abs().max().item()
rel_l2_err = torch.norm(ref - actual) / torch.norm(ref)
assert not has_nan, f"出现NaN,shape={shape}, dtype={dtype}"
assert not has_inf, f"出现Inf,shape={shape}, dtype={dtype}"
assert max_abs_err < atol, f"最大绝对误差 {max_abs_err} 超过阈值 {atol}"
assert rel_l2_err < rtol, f"相对L2误差 {rel_l2_err} 超过阈值 {rtol}"
print(f"✓ shape={shape}, dtype={dtype}, max_abs_err={max_abs_err:.2e}")
```
**验证要点**:
- 覆盖模型中真实出现的所有shape,不要只测方便的尺寸
- 覆盖边界条件:空张量、最大维度、非对齐尺寸
- BF16算子要特别检查`bitwise equal`是否在预期范围内
### 6.2 第二层:模块级验证
**目标**:确保替换算子后,整个模型层(如Attention、MLP)的输出一致。
```python
def verify_module(custom_module, ref_module, hidden_size, num_layers=1, batch=2, seq_len=512):
"""模块级输出对比"""
torch.manual_seed(42)
hidden_states = torch.randn(batch, seq_len, hidden_size, dtype=torch.bfloat16, device="cuda")
with torch.no_grad():
ref_out = ref_module(hidden_states)
actual_out = custom_module(hidden_states)
# 逐层对比
for i, (r, a) in enumerate(zip(ref_out, actual_out)):
if isinstance(r, torch.Tensor):
err = (r.float() - a.float()).abs().max().item()
print(f" Layer {i} output max_abs_err: {err:.2e}")
assert err < 1e-3, f"Layer {i} 输出误差过大"
```
### 6.3 第三层:模型级验证
**目标**:固定输入和种子,对比完整模型的生成结果。
```python
def verify_generation(model, tokenizer, test_prompts, num_tokens=128):
"""模型级生成一致性验证"""
results = []
for prompt in test_prompts:
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
# 多次运行,检查一致性
outputs_list = []
for seed in [42, 123, 456]:
torch.manual_seed(seed)
with torch.no_grad():
outputs = model.generate(**inputs, max_new_tokens=num_tokens, do_sample=False)
outputs_list.append(outputs[0].cpu().tolist())
# 贪心解码应完全一致
if all(o == outputs_list[0] for o in outputs_list):
print(f"✓ Prompt一致性通过: {prompt[:30]}...")
else:
# 计算Token重合率
match = sum(a == b for a, b in zip(outputs_list[0], outputs_list[1]))
rate = match / len(outputs_list[0])
print(f"⚠ Prompt重合率: {rate:.2%}, prompt={prompt[:30]}...")
results.append((prompt, rate, outputs_list))
return results
```
**关键指标**:贪心解码(`do_sample=False`)下,相同输入应产生**完全一致**的输出。如果出现偏差,说明存在数值不稳定。
### 6.4 第四层:业务级验证
**目标**:在真实业务场景下验证模型质量无感知下降。
```python
def verify_quality(model, tokenizer, test_dataset, baseline_scores):
"""业务级质量验证"""
predictions = []
for item in test_dataset:
output = inference(model, tokenizer, item["prompt"])
predictions.append(output)
# 1. 评测集得分对比
current_scores = evaluate(predictions, test_dataset["references"])
for metric, score in current_scores.items():
delta = score - baseline_scores[metric]
status = "✓" if abs(delta) < 0.005 else "⚠"
print(f"{status} {metric}: {score:.4f} (baseline: {baseline_scores[metric]:.4f}, delta: {delta:+.4f})")
# 2. 人工抽检:随机抽取50条,检查是否有明显语义偏移
# 3. 长文本稳定性:生成2048+ token,检查是否出现退化(重复、胡言乱语)
```
**验收标准**:
- 主流评测集(MMLU、C-Eval等)得分波动 < 0.5%
- 1000条业务测试集正确率波动 < 0.5%
- 长文本生成无退化现象
- 无NaN/Inf输出
## 七、实战调优参数清单
基于上述分析,整理一份DCU推理部署的显存与正确性调优清单:
### 7.1 显存优化参数
```bash
#!/bin/bash
# === 显存优化 ===
# KV Cache使用FP8,显存减半
--kv-cache-dtype fp8
# 合理限制显存利用率,预留5~15%缓冲
--gpu-memory-utilization 0.85
# 开启分块Prefill,降低长prompt峰值
--enable-chunked-prefill
# 调整block size提升PagedAttention利用率
--block-size 16
# 限制最大并发,避免KV Cache爆炸
--max-num-seqs 64
# === 运行时内存策略 ===
export PYTORCH_HIP_ALLOC_CONF=max_split_size_mb:128,garbage_collection_threshold:0.8
export HIP_FORCE_PERSISTING_L2=1
# === NUMA绑定 ===
numactl --cpunodebind=0 --membind=0 vllm serve ...
```
### 7.2 正确性检查清单
- [ ] 所有自定义算子通过算子级数值验证(max_abs_err < 1e-5)
- [ ] 替换模块后通过模块级输出对比
- [ ] 贪心解码下相同输入产生完全一致输出
- [ ] 评测集得分波动 < 0.5%
- [ ] 长文本(>2048 token)生成无退化
- [ ] 多轮对话无上下文累积误差
- [ ] 边界输入(空prompt、超长prompt、特殊字符)不触发异常
### 7.3 常见OOM排查流程
```
OOM报错
├─ 检查 rocm-smi 实际显存占用
│ ├─ 占用接近100% → 真正的显存不足 → 降并发/量化/降上下文
│ └─ 占用<80% → 碎片或预分配问题
│ ├─ 检查 max-model-len × max-num-seqs 是否过大
│ ├─ 检查 gpu-memory-utilization 设置
│ └─ 检查是否有其他进程占用显存
├─ 检查 dmesg 是否有驱动层OOM
│ └─ 注意:dmesg中的OOM可能属于其他容器,不要误判
├─ 检查 NUMA 绑定是否正确
└─ 检查模型加载方式是否产生临时峰值
```
## 八、总结
1. **DCU显存不是一块简单的HBM**:它有严格的层级结构,HBM带宽才是真正的瓶颈,容量只是表象
2. **推理显存账本有四大项**:权重、KV Cache、激活值、碎片与运行时开销。KV Cache和碎片是"余量OOM"的主要元凶
3. **静默错误比OOM更危险**:不报错不代表正确,必须建立从算子到业务的四层验证体系
4. **优化要先定位再动手**:用`rocprof`看热点,用性能计数器看瓶颈类型,用逐层验证保正确性
5. **DCU与CUDA有本质差异**:wave64调度、共享内存bank数、BF16处理等差异,决定了不能直接照搬CUDA经验
理解了显存账本和验证方法论,你就能在DCU上既"跑得动"又"跑得对",真正把国产算力用出性价比。
---