第4章 内存性能瓶颈定位

内存,说白了就是推理服务的「短期记忆」。带宽不够、延迟太高、泄漏不止——随便哪个出问题,推理延迟就会飙升,甚至直接 OOM 挂掉。我这些年排查过的线上事故,有一小半都和内存脱不了干系。这一章咱们就把内存带宽、内存延迟、泄漏检测、显存分析这几个硬骨头啃下来,最后用一个真实泄漏案例收尾。

内存性能瓶颈定位 内存带宽 STREAM / likwid-bench 内存延迟 mlock / perf mem / Intel MLC 内存泄漏检测 Valgrind / ASan / heaptrack 显存 (GPU Memory) nvidia-smi / py-spy / torch.cuda 实战:推理服务泄漏 从现象到根因到修复

4.1 内存带宽——推理的「水管」够粗吗?

内存带宽,就是 CPU 或 GPU 从内存里读数据的速率。推理任务里,模型权重和中间激活值需要反复搬运,带宽不够,计算单元就得干等。我遇到过一个大模型服务,GPU 利用率死活上不去,一看内存带宽跑满了,数据搬运成了瓶颈。

怎么测?Linux 下最经典的工具是 STREAM。它跑四种操作:Copy、Scale、Add、Triad。跑出来的数值和硬件理论峰值对比,就能知道你的「水管」通不通。

快速检查带宽的命令:

# 安装 STREAM
wget https://www.cs.virginia.edu/stream/FTP/Code/stream.c
gcc -O3 -march=native -DSTREAM_ARRAY_SIZE=200000000 -DNTIMES=20 stream.c -o stream
./stream

# 输出示例(DDR4-3200 双通道):
# Copy:   48000 MB/s
# Scale:  47000 MB/s
# Add:    51000 MB/s
# Triad:  50000 MB/s

如果实测带宽只有理论值的 60% 以下,你得检查内存通道数、NUMA 绑定、或者是不是跑在低频模式。我个人习惯在压测推理服务前,先跑一遍 STREAM 打个底。

4.2 内存延迟——每次访问都在「等」

带宽管吞吐,延迟管响应。推理服务里,随机访问模式多(比如 Attention 的 KV Cache),延迟高了,单次推理时间就拉长。我见过一个极端案例:跨 NUMA 节点访问内存,延迟直接翻倍,推理 P99 从 50ms 飙到 120ms。

测量延迟可以用 Intel MLC 或者 perf mem。下面是用 perf 看延迟分布的方法:

# 用 perf 采样内存访问延迟
perf mem record -e l1d-loads,l1d-stores,llc-loads,llc-stores -- ./your_inference_app
perf mem report

# 重点关注:
# - L1 miss rate 是否过高
# - LLC miss 比例(跨片访问的迹象)

💡 避坑指南: 我曾经在 AMD EPYC 服务器上发现,默认 NUMA 策略下推理进程被分配到两个 socket 上,跨片延迟多了 80ns。用 numactl --cpunodebind=0 --membind=0 绑定后,延迟立刻降下来。

4.3 内存泄漏检测——别让内存「只借不还」

内存泄漏是推理服务的慢性病。一开始没事,跑几小时、几天后,内存占用越来越高,最后 OOM 被 kill。我排查过最头疼的一个泄漏,是 Python 的 torch.Tensor 在循环中没释放引用,导致显存和内存双双泄漏。

工具方面,我按场景分三类:

工具适用场景典型命令
Valgrind (memcheck)C/C++ 服务,精确到行valgrind --leak-check=full ./server
AddressSanitizer (ASan)编译时插桩,速度更快gcc -fsanitize=address -g ...
heaptrack (KDE)Python/C++ 混合,可视化heaptrack ./server
tracemalloc (Python)纯 Python 服务,低开销python -m tracemalloc

对于 Python 推理服务,我推荐先用 tracemalloc 快速定位:

import tracemalloc

tracemalloc.start()
# ... 跑推理循环 ...
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')

for stat in top_stats[:10]:
    print(stat)

⚠️ 注意: tracemalloc 会带来 5-10% 的性能开销,生产环境慎用。我一般先在压测环境跑,确认泄漏源后再上线修复。

4.4 显存(GPU Memory)分析——显存爆了,推理就停了

显存是推理服务的「黄金地段」。模型权重、KV Cache、中间激活全挤在里面。显存不够,要么 OOM,要么触发 swap 到系统内存,性能直接崩盘。

我常用的三板斧:

举个例子,用 PyTorch 自带的内存分析:

import torch

# 在推理循环中打印显存快照
print(torch.cuda.memory_summary(device='cuda:0'))

# 或者用更轻量的方式
allocated = torch.cuda.memory_allocated()
reserved = torch.cuda.memory_reserved()
print(f"已分配: {allocated/1024**2:.1f} MB, 预留: {reserved/1024**2:.1f} MB")

如果发现 reserved 远大于 allocated,说明缓存池没释放。我遇到过推理框架的显存缓存策略太激进,导致显存越占越多,最后不得不手动调 torch.cuda.empty_cache()。

4.5 实战案例:排查推理服务的内存泄漏

去年我接手一个线上事故:某 LLM 推理服务运行 6 小时后,内存从 8GB 涨到 32GB,然后被 OOM Killer 干掉。重启后 6 小时又挂,循环往复。

排查过程:

  1. 先用 top + /proc/meminfo 确认趋势:写了个脚本每 5 分钟记录 RSS,发现线性增长,排除缓存抖动。
  2. 用 Valgrind 跑 C++ 推理引擎:没发现明显泄漏,怀疑是 Python 层。
  3. 启用 tracemalloc:在压测环境跑 1000 次推理,抓快照对比。发现 tokenizer.encode() 每次调用都会新增约 2KB 的 list 对象,且从未释放。
  4. 定位根因:原来代码里用了一个全局列表缓存 token 序列,但没做清理。每次请求追加,越积越多。

修复前后对比:

# 修复前(泄漏)
token_cache = []
def process(text):
    tokens = tokenizer.encode(text)
    token_cache.extend(tokens)  # 每次追加,永不释放
    ...

# 修复后
def process(text):
    tokens = tokenizer.encode(text)
    # 只保留当前请求的 tokens,用完即弃
    result = model.infer(tokens)
    return result

修复后,内存稳定在 9GB 左右,再也没 OOM 过。你看,有时候泄漏就是这么「朴实无华」——一个全局列表,就能让服务崩溃。

📌 我的经验总结: 排查内存泄漏,别一上来就用复杂工具。先看趋势,再缩小范围,最后用精确工具定位。顺序对了,事半功倍。