蚁工厂
26-05-24 15:55 微博认证:科技博主

SGLang 团队的Chayenne Zhao记录了他们排查大模型推理系统OOM 的过程。

在大模型推理系统中,OOM 往往被简单理解为“显存不够”。但真正排查起来,问题远比这句话复杂:到底是模型权重占满了显存,KV cache 分配失败,activation 空间不足,还是多模态 encoder 没有拿到足够资源?在 SGLang Omni 迁移到 H100 并验证 Qwen3 Omni 的过程中,他们遇到了一次看似是准确率回退、实则由显存分配策略引发的问题。本文围绕这次排查过程,解释 `mem_fraction_static` 的真实含义,分析不同 OOM 场景下应如何调整参数,并记录如何通过 auto-tune 机制让 H100 重新稳定运行。

# 当 SGLang OOM 时,到底是哪块内存不够?

大约在我们全力开发 SGLang Omni 两个月后,也就是今年四月左右,我们在已有的 H200 开发机和 H20 CI 机器之外,又拿到了一台全新的 H100。这意味着又多了一台机器可以参与社区开发。拿到新机器当然令人兴奋,所以我们很快把这台 H100 投入生产,用来处理 #280。

PR 280 本身很简单。它看起来改动接近 2000 行,但逻辑并不复杂。简而言之,我们之前有一个脚本用于在 SeedTTS 数据集上 benchmark Omni Model Voice Clone 的性能,另一个脚本用于测试 Omni Model Voice Clone 的正确性。显然,这两个脚本可以合并:在测正确性的同时,也可以自然地测性能。PR 280 做的正是这个合并。毫无疑问,这个任务非常清晰,AI 完全可以完美完成。于是 Hao Jin,也就是 AAA Google Tennis Coach Jin,很快完成了 280 本身,然后开始测试这个合并后的脚本。

问题就在这里出现了,而且非常严重。Coach Jin 用合并后的脚本在全量数据集上测 Qwen3 Omni TTS 的 WER,结果竟然超过了 3.0。需要注意,WER 是越低越好的指标,所以这立刻让我们高度警觉。我们开始分析是什么导致了如此大的正确性回退。在 #280 之前,我们已经搭建了一个看起来武装到牙齿的 CI 系统:Qwen3 Omni 会在每次 commit 上验证一个包含 50 个样本的 SeedTTS 子集,确保完全没有性能或正确性回退。因此,当新脚本显示出明显的性能损失时,我们立刻产生了几个不太妙的猜测:

- 我们的 CI 是假的;某个更早的 PR 其实已经破坏了 Qwen3 Omni 的准确率,但 CI 没有捕捉到。
- 我们的 benchmark 有问题;也许我们在把两个 benchmark 脚本合并成一个时犯了错。

后来我们在 SGLang Omni 开发群里讨论这个问题,发现事情可能没有一开始想得那么麻烦。在前面的故事里,我几乎是顺口提了一下机器迁移。PR 280 之前,我们一直使用 H200 作为开发机。到了 PR 280 时,我们换到了 H100。一开始我没有多想,但实际上问题恰好就出在这里。我记得 Coach Jin 第一次告诉我他在验证 280 时,提到旧的启动 server 脚本已经无法把 server 启起来了。这让我非常困惑。那是一个周末,我在 Mountain View 跑步时,一直在想:“这到底是怎么回事?”后来他和 Claude 一起 debug,把 SGLang Omni 中 Qwen3 Omni 的 `mem_fraction_static` 提高到 `0.8`,server 才成功启动。

现在回头看,那其实就是问题本身。我原本以为从 H200 迁移到 H100 不会有什么问题,甚至当性能回退出现时,我也没有把原因归到 H100 上。直到和群里的聪明人聊过之后,我才意识到,SGLang Omni 的 Thinker 之前把 `mem_fraction_static` 硬编码成了 `0.7`。结果就是,一张 80GB 的 H100 根本无法启动 Qwen3 Omni 的 Thinker,也就是 30B MoE,进而导致了上面的性能回退。即使我们把 `mem_fraction_static` 提高到 `0.8`,server 也只能勉强启动,而且仍然有相当一部分请求因为内存压力而失败。

这篇短文会分享我们如何推理并解决这个问题。具体来说,我们会讨论:

1. `mem_fraction_static` 这个参数到底在做什么。SGLang OOM 时,到底是哪块内存不够?
2. 为什么在 H100 上把 `mem_fraction_static` 设置为 `0.8` 后,server 能启动,但很多请求仍然会因为内存失败。
3. 我们如何使用 SGLang 的 `mem_fraction_static` auto-tune 机制,让 H100 重新可用。

致谢:感谢 Huapeng、Yifei、Ratish、Coach Jin、xuesong 和 yifei。

## `mem_fraction_static` 到底分配了什么?

SGLang 官方对这个参数的描述是:

> 用于静态分配的显存比例,也就是模型权重和 KV cache memory pool。如果遇到 out-of-memory 错误,请使用更小的值。

设 `--mem-fraction-static = y`。这里的 `y` 表示:在加载模型权重之前,也就是 `torch.distributed.init` 之后,SGLang 会测量当前可用的 GPU 显存,并按照 `y : (1-y)` 的比例把它分成两个逻辑区域。

- `y` 这一部分用于模型权重和 KV cache pool。
- `(1-y)` 这一部分预留给 inference 过程中的 activations、CUDA graph buffers 以及其他动态开销。

这里有一点和官方文档并不完全一致,但和我们这次在 H100 上遇到的问题直接相关:文档笼统地说,如果看到 OOM,就应该“把 `y` 调小”。但在我们的实际操作里,我们必须把 `y` 从 `0.7` 提高到 `0.8`,server 才能勉强启动。事实上,当 OOM 出现时,到底应该把 `y` 调小还是调大,需要更深入地判断。要理解这一点,我们先要理解 SGLang 启动过程中的两个变量:`pre_model_load_memory` 和 `post_model_load_memory`。

具体来说,SGLang 会使用同一个工具函数 `get_available_gpu_memory` 读取当前可用的 GPU 显存:它先调用 `torch.cuda.empty_cache()`,然后用 `torch.cuda.mem_get_info` 读取当前 free memory,并转换成 GB。在 TP 这样的多卡设置下,它还会取各个 rank 的 MIN,避免某一张特别紧张的 GPU 被其他 GPU “平均掉”。

### `pre_model_load_memory`

在 `init_torch_distributed` 快结束时,distributed init 刚完成,而 `load_model` 尚未发生。这时返回的值会被记录为 `pre_model_load_memory`。

它不是 H100 物理上的 80GB,而是在扣除 CUDA context、NCCL buffers,以及同一张 GPU 上其他进程占用的显存之后,“driver 认为此刻这张 GPU 还能给我的显存”。

### `post_model_load_memory`

在 `_profile_available_bytes` 内部,权重已经通过 `load_model` 加载完成,如果有 LoRA 相关预分配也已经完成,但 KV pool 尚未创建。SGLang 会再次调用 `get_available_gpu_memory`,并记录结果为 `post_model_load_memory`。

因此,我们几乎可以把:

```text
pre_model_load_memory - post_model_load_memory
```

视为权重和 LoRA 相关预分配所占用的显存。

从某种意义上说,我们也可以通过参数量乘以数据精度直接计算模型权重大小,但 `pre_model_load_memory - post_model_load_memory` 实际上更准确。

基于这个值,我们把 `pre_model_load_memory` 分成两个逻辑区域:

- 静态区域,包含 weights、KV、LoRA allocation 等:`y * pre_model_load_memory`
- 动态区域,包含 activations 等:`(1-y) * pre_model_load_memory`

从静态区域中减去权重和 LoRA 相关预分配占用的显存后,剩下的部分就是可用于 KV 的显存:

```text
memory_for_kv = y * pre_model_load_memory - (pre_model_load_memory - post_model_load_memory)
```

对应的源码在 `model_runner_kv_cache_mixin.py` 中:

```python
def _profile_available_bytes(self, pre_model_load_memory: int) -> int:
post_model_load_memory = get_available_gpu_memory(...)

rest_memory = post_model_load_memory - pre_model_load_memory * (
1 - self.mem_fraction_static
)
...
return int(rest_memory * (1 << 30))
```

随后,`pool_configurator.py` 会把这里返回的值,也就是 bytes,转换成 `max_total_num_tokens`。如果它小于等于 0,就会抛出:

```text
Not enough memory. Please try to increase --mem-fraction-static.
```

这正是我们在 H100 上看到的启动失败。

有了上面的讨论,我们可以继续问:当 OOM 出现时,应该增大还是减小 `mem_fraction_static`?事实上,这没有一个统一答案,因为不同的 OOM 原因对应不同的调整方向。

## 三类典型 OOM 场景

### 1. 启动 OOM:KV Cache 分配失败

server 在启动阶段直接失败,并且 `rest_memory` 是负数。换句话说,静态区域 `y * pre_model_load_memory` 减去权重之后,连分配 KV pool 的显存都不够。

在这种情况下,处理方向正好和官方文档里“把 `y` 调小”的说法相反:我们需要增大 `y`。

以 H100 80GB + Qwen3-Omni 60GB 为例。假设 init 消耗大约 2GB,因此 `pre ~= 78 GB`:

```text
mem_fraction_static = 0.7(默认值)
pre ~= 78 GB
reserved = (1-y) * pre ~= 23.4 GB
post ~= 78 - 60 ~= 18 GB
memory_for_kv ~= -5.4 GB -> OOM

mem_fraction_static = 0.85
reserved ~= 11.7 GB
memory_for_kv ~= 6.3 GB -> KV Cache 可以分配
```

需要注意,上面的 78GB 只是估算值。`pre_model_load_memory` 取决于 driver、CUDA context、NCCL/通信库,以及同一张 GPU 上其他进程占用的显存。

如果同一张 GPU 上还有其他服务,比如本地 training job 或 monitoring daemon,`pre_model_load_memory` 可能会直接降到 70GB,甚至更低。这时就算增大 `y` 也可能救不回来。

这也是某些情况下“公式看起来还有足够显存,但启动仍然 OOM”的原因。这个场景下,正确的处理方式是先释放同一张 GPU 上的显存,然后再调整 `mem_fraction_static`。

### 2. SGLang 内部 runtime OOM

server 能启动,运行一段时间后,SGLang 自己 OOM。

这个问题其实很有意思:SGLang 本身不会仅仅因为 KV cache 不足就 OOM。当某个请求过长,KV cache 放不下这个巨大请求时,SGLang 的策略是 retract 这个请求,也就是把它放回 scheduler queue。等其他请求的 KV cache 被清理后,再把这个请求放回某个 batch。

因此,SGLang 不会因为 KV cache 分配不够而 OOM,前提是 `y` 虽小但仍然足以放下权重。

如果 SGLang 在 runtime OOM,只有两种可能:

1. **activation memory 预留不够**

runtime 期间 activations 直接把当前 GPU 撑爆。这在逻辑上是可能的。

正如前面所说,SGLang 不会因为 KV Cache 分配不够而 OOM,但它确实可能因为 activations 没有获得足够空间而 OOM。这种情况下,我们应该减小 `y`,给 activations 留出更多显存。

2. **KV cache 和 activations 都没有问题,但 GPU 上还有其他进程**

例如,如果你是学生,也许同实验室的人正在同一张卡上运行一个显存剧烈波动的神秘任务;或者你正在运行 RL workload,其中 training engine 存在严重的 memory fragmentation 或 leakage。

此时从逻辑上说,我们应该严格管理其他进程。但在实际中,我们也许能做的只是降低 SGLang 的 mem static fraction,让它刚好足以加载权重,使用更小的 KV Cache,然后祈祷 activations 不会和其他进程发生显存冲突,把 server 搞到 OOM。

这就是 SGLang 参数文档里 `Use a smaller value if you see out-of-memory errors` 的含义。

所以文档是对的,但并不完全。若启动失败是因为 KV Cache 分配失败,应该增大 `y`;若 OOM 发生在 runtime,则应该减小 `y`。

### 3. Multi-Modal Encoder OOM Error

回到 SGLang Omni,这里还有一个转折。

前面提到,在我们把 Qwen3 Omni Thinker 的 Mem Fraction 提高到 `0.8` 后,server 成功启动了。但用这个参数测出的 SeedTTS WER 仍然很高。更仔细地看后,我们发现很多请求因为 OOM 失败,但这既不是启动阶段的 KV Cache 分配失败,也不是 runtime activation memory 不足。

这就让人困惑了。继续追踪后,我们发现 video encoder 和 audio encoder 没有分配到足够显存,导致一些请求根本没有被送到 Thinker。WER 自然就非常糟糕。

对此,我们目前的方案并不优雅:临时使用 `encoder_mem_reserve` 参数,提前为 video encoder 和 audio encoder 预留显存。当然,这最终应该和 SGLang VLM 的处理逻辑对齐。

## H200 vs H20 vs H100:Hopper 卡上的最后一步

这就是整个故事。

我们之前一直在 H200 上开发,而 CI 机器使用的是 96GB 的 H20。很巧的是,如果 `thinker_mem_static` 被硬编码为 `0.7`,那么留给 KV cache 的显存大约是:

```text
96 * 0.7 - 60 = 7.2 GB
```

刚好足够运行。

当然,我们现在已经不再把 `thinker_mem_static` 硬编码为 `0.7`。相反,我们参考 SGLang,使用 auto-tune 机制,让每台机器尽可能多地使用可用显存。

现在,在 H100 上 SGLang Omni 也可以顺利运行了,只是留给 multi-modal encoder 的显存仍然相当紧张。一些长序列 video inputs 仍然可能触发 OOM。这是预期之内的,我们会继续做一些优化,也许会支持 FP8,或者学习 SGLang 更先进的 memory management 机制。

写到这里,这篇短文基本结束了。

南加州的五月一天天热起来。我记得大约去年的这个时候,我离开学校去做工业界实习,在六月初决定离开学术界,然后在 2025 年 11 月正式开始全职工作。

有时候我会想,工业界真的很不一样,startup 更是如此。那些曾经从学校看起来光芒万丈的名人,换到工业视角再看,也会显出许多令人失望之处。到头来,想要“定乎内外之分,辩乎荣辱之境”,实在很难。

至于我自己,在开始工作后的半年里,无论心态还是认知,都发生了很大变化。SGLang Omni 是我全职工作后的第一个项目。虽然过程有许多波折,但我很感谢所有朋友的支持与全力投入。新的旅程从这里开始。

> 孩子们会在余生都记得这样一幕:他们的父亲庄重地坐在餐桌主位上,因为长夜与深思而消瘦,激动得微微颤抖,然后向他们揭示自己的发现:“地球是圆的,像一个橙子。”

发布于 山东