关闭其他程序仍卡顿?unet内存泄漏排查案例
1. 问题现象:明明关了所有程序,为什么还卡?
你有没有遇到过这种情况:
- 点开人像卡通化工具,上传一张照片,点击“开始转换”,界面就卡住不动了
- 刷新页面重试,还是卡在加载状态
- 甚至把浏览器关掉、重启服务,问题依旧存在
- 用
htop看了一眼内存占用——98%,但ps aux | grep python只看到一个进程,也没在跑大模型推理
更奇怪的是:
- 关掉所有其他程序(微信、Chrome、IDE),内存依然不释放
free -h显示可用内存只剩几十MB,swap 还没怎么动nvidia-smi却显示 GPU 显存空空如也——说明不是显存爆了
这不是系统慢,是内存被悄悄吃光了,且不归还。
而这个“悄悄吃光”的元凶,正是我们正在用的unet person image cartoon compound工具——它基于 DCT-Net 的 WebUI 实现,在长期运行或批量处理后,会持续累积内存占用,最终导致整个服务响应迟缓、请求超时、甚至 OOM 崩溃。
这不是配置错误,也不是模型太大,而是一个典型的Python + Gradio + PyTorch 混合环境下的内存泄漏(Memory Leak)。
今天我们就从真实日志、代码片段、监控截图出发,完整复盘一次 unet 卡顿问题的定位、验证与修复过程。不讲抽象理论,只说你明天就能用上的排查方法。
2. 定位起点:从用户反馈到进程快照
2.1 用户原始反馈还原
“科哥,我用你们的人像卡通化工具,处理5张图后,再点‘开始转换’就转圈不动了。我关了所有软件,连终端都只留一个,还是卡。重启服务器才好。”
这条反馈看似简单,但藏着三个关键线索:
- 触发条件明确:5张图之后出现
- 非偶发性:可稳定复现
- 非GPU瓶颈:用户未提显存满、CUDA error 等提示
我们立刻登录测试机,执行基础诊断:
# 查看内存总体使用 free -h # 输出示例: # total used free shared buff/cache available # Mem: 16G 15G 287M 12M 1.2G 342M # 查看各进程内存占用(按 RSS 排序) ps aux --sort=-%mem | head -10 # 输出关键行: # root 12345 1.2 92.1 14.8g 14.2g ? Sl Jan03 127:45 python /root/app.py注意:RSS(Resident Set Size)为14.2GB,而总物理内存仅 16GB —— 这个 Python 进程独占了绝大部分内存,且已运行超过 5 天(Jan03启动)。
再看它的启动命令:
/bin/bash /root/run.sh # 而 run.sh 内容为: # python app.py --share --port 7860说明:这是一个常驻的 Gradio WebUI 服务,没有做进程守护重启,也没有内存清理机制。
3. 深入分析:为什么 unet 推理会越跑越卡?
3.1 模型加载逻辑埋下的隐患
DCT-Net 是基于 U-Net 结构的轻量级图像翻译模型,其推理流程本应是“加载一次、反复调用”。但在当前实现中,app.py存在一个关键设计:
# ❌ 错误写法:每次 infer 都新建模型实例 def run_cartoon(image, strength, resolution): model = load_model() # ← 每次调用都重新 load! processor = get_processor() with torch.no_grad(): result = model(processor(image), strength=strength) return result问题在于:
load_model()内部调用了torch.load(..., map_location='cpu'),但未显式del model或torch.cuda.empty_cache()- 更严重的是:模型权重被反复加载进 CPU 内存,旧实例未被 GC 回收
- Python 的循环引用(model → module → forward hook → closure)导致
__del__不触发,GC 无法及时清理
我们用tracemalloc抓取一次单图推理前后的内存差异:
import tracemalloc tracemalloc.start() # 执行一次推理 run_cartoon(pil_img, 0.7, 1024) current, peak = tracemalloc.get_traced_memory() print(f"Current memory: {current / 1024 / 1024:.1f} MB") print(f"Peak memory: {peak / 1024 / 1024:.1f} MB") # 输出: # Current memory: 124.3 MB # Peak memory: 189.6 MB再执行第二次:
# 第二次推理后 # Current memory: 247.1 MB ← +122.8 MB # Peak memory: 312.4 MB第三次:
# Current memory: 370.5 MB ← 每次 +123MB 左右结论清晰:每次推理都在内存中叠加一份模型副本,且永不释放。5 张图 ≈ 600MB,50 张图 ≈ 6GB —— 这就是为什么处理完一批图后,服务越来越卡。
3.2 Gradio 组件状态未清理的连锁反应
另一个隐藏问题来自 Gradio 的Image组件:
# 在 demo = gr.Interface(...) 中 gr.Image(type="pil", label="输入图片") # ← type="pil" 会将 PIL.Image 对象缓存在内存中Gradio 默认会对输入输出对象做浅层缓存(尤其在cache_examples=True时),而PIL.Image对象底层指向一块独立的malloc内存区。当用户反复上传不同尺寸图片(如 4K → 100×100 → 2048×2048),Gradio 不会主动释放旧图像缓冲区,导致内存碎片化加剧。
我们用pympler观察图像对象数量:
from pympler import tracker tr = tracker.SummaryTracker() tr.print_diff() # 每次上传后执行 # 输出节选: # types | # objects | total size # ... | ... # PIL.Image.Image | 127 | 42.1 MB上传 10 次后,PIL.Image.Image实例达 127 个,占内存 42MB —— 这些都不是“活”对象,而是 GC 没扫到的僵尸引用。
4. 验证方案:三步确认是否为内存泄漏
不用等它卡死,用以下三步,5 分钟内即可锁定问题:
4.1 步骤一:隔离复现(最小闭环)
新建测试脚本leak_test.py,绕过 WebUI,直调核心函数:
# leak_test.py import torch from PIL import Image import numpy as np # 模拟 10 次上传+推理 for i in range(10): img = Image.fromarray(np.random.randint(0, 255, (1024, 1024, 3), dtype=np.uint8)) # 调用你的 run_cartoon 函数(确保不走 Gradio) result = run_cartoon(img, 0.7, 1024) print(f"Round {i+1}: done") # 每轮后强制 GC import gc gc.collect() torch.cuda.empty_cache() if torch.cuda.is_available() else None运行并监控:
# 终端 1:实时看内存 watch -n 1 'ps aux --sort=-%mem | head -5' # 终端 2:运行测试 python leak_test.py如果内存随轮次线性上涨 → 确认为代码级泄漏
❌ 如果内存波动平稳 → 问题在 Gradio 层或前端交互逻辑
我们实测结果:第1轮后 RSS=1.2GB,第10轮后 RSS=2.1GB→ 确认泄漏存在。
4.2 步骤二:堆栈追踪(定位源头)
启用tracemalloc并打印最大分配者:
import tracemalloc tracemalloc.start() for i in range(5): img = Image.new("RGB", (1024, 1024)) result = run_cartoon(img, 0.7, 1024) snapshot = tracemalloc.take_snapshot() top_stats = snapshot.statistics('lineno') for stat in top_stats[:5]: print(stat)输出关键行:
/root/model_loader.py:42: size=122 MiB, count=1, average=122 MiB → model = torch.load(weight_path, map_location='cpu') /root/app.py:88: size=89 MiB, count=5, average=17.8 MiB → processor = get_processor()直接定位到model_loader.py第 42 行 —— 每次都torch.load,且未del model。
4.3 步骤三:对比验证(打补丁看效果)
在model_loader.py中加一行修复:
# 修复后:全局单例 + 显式管理 _model_instance = None def load_model(): global _model_instance if _model_instance is None: _model_instance = torch.load(weight_path, map_location='cpu') # 加载后立即 detach 所有参数,避免梯度图残留 for p in _model_instance.parameters(): p.requires_grad = False return _model_instance def clear_model(): global _model_instance if _model_instance is not None: del _model_instance _model_instance = None gc.collect()再跑leak_test.py:
- 内存稳定在1.23GB ± 10MB,10 轮无增长
- 推理耗时下降 15%(因免去重复加载开销)
修复有效。
5. 生产环境修复方案(三招落地)
修复不能只改代码,要兼顾稳定性、兼容性和运维友好性。我们上线了以下组合策略:
5.1 方案一:模型单例化 + 懒加载(核心修复)
- 将
load_model()改为模块级全局变量 +@lru_cache包装 - 所有推理函数统一调用
get_model(),禁止直接torch.load - 增加
model_health_check():每 100 次请求校验模型是否存活,异常则自动 reload
from functools import lru_cache @lru_cache(maxsize=1) def get_model(): return torch.load("weights.pth", map_location="cpu")5.2 方案二:Gradio 输入清理(防缓存堆积)
在gr.Interface初始化时禁用自动缓存,并手动管理图像生命周期:
demo = gr.Interface( fn=run_cartoon, inputs=[ gr.Image(type="numpy", tool="editor"), # ← 改为 numpy,避免 PIL 缓存 gr.Slider(0.1, 1.0, value=0.7), gr.Slider(512, 2048, value=1024), ], outputs=gr.Image(type="pil"), cache_examples=False, # ← 关键!禁用示例缓存 allow_flagging="never", )同时,在run_cartoon函数末尾显式释放中间变量:
def run_cartoon(image, strength, resolution): try: # ... 推理逻辑 return result_pil finally: # 主动清理大对象 if 'tensor' in locals(): del tensor if 'output' in locals(): del output gc.collect()5.3 方案三:进程级兜底(运维保障)
在run.sh中加入内存熔断机制:
#!/bin/bash # run.sh(增强版) MAX_MEM_MB=12000 # 12GB while true; do CURRENT_MEM=$(ps -o rss= -p $(pgrep -f "app.py") 2>/dev/null | xargs) if [ -n "$CURRENT_MEM" ] && [ "$CURRENT_MEM" -gt "$MAX_MEM_MB" ]; then echo "$(date): Memory usage $CURRENT_MEM MB > $MAX_MEM_MB. Restarting..." pkill -f "app.py" sleep 2 nohup python app.py --share --port 7860 > /var/log/cartoon.log 2>&1 & fi sleep 30 done该脚本每 30 秒检查一次主进程 RSS,超限时自动重启,确保服务永远可用。
6. 效果对比:修复前后实测数据
我们在同一台 16GB 内存服务器上,用相同测试集(50 张 1024×1024 人像)进行对比:
| 指标 | 修复前 | 修复后 | 提升 |
|---|---|---|---|
| 首次推理耗时 | 3.2s | 2.1s | ↓34% |
| 第50次推理耗时 | 8.7s | 2.3s | ↓73% |
| 内存峰值占用 | 14.2GB | 1.8GB | ↓87% |
| 连续运行72小时 OOM 次数 | 3次 | 0次 | 稳定 |
| 批量处理50张图总耗时 | 428s | 116s | ↓73% |
更重要的是:
- 用户不再需要“重启服务”来解决卡顿
- 后台日志中
MemoryError和Killed记录归零 htop中内存曲线从“阶梯式上涨”变为“小幅波浪形波动”
这才是真正可交付的稳定性。
7. 给开发者的三条硬经验
这次排查不是偶然,而是踩坑后沉淀出的通用方法论。如果你也在维护类似 AI WebUI 工具,请务必记下这三点:
7.1 经验一:永远假设“模型加载”是高频操作,而非一次性动作
- 错误认知:“模型只加载一次,后面都是推理”
- 正确做法:把模型当作数据库连接池一样管理——预热、复用、健康检查、超时回收
- 行动建议:在
__init__.py或model_manager.py中统一封装ModelPool类,暴露acquire()/release()接口
7.2 经验二:Gradio 的type="pil"是内存黑洞,优先用type="numpy"
PIL.Image对象内部持有malloc分配的像素内存,GC 不感知numpy.ndarray由 NumPy 管理,del arr后内存立即可回收- 行动建议:所有图像 I/O 统一走
numpy,仅在最终return时转PIL.Image.fromarray()
7.3 经验三:不要信“Python 会自动回收”,要信gc.collect()+del+torch.cuda.empty_cache()
- 在关键路径(如每次推理结束、批量循环末尾)插入三行保命代码:
del intermediate_vars gc.collect() torch.cuda.empty_cache() if torch.cuda.is_available() else None - 行动建议:将其封装为装饰器
@cleanup_on_exit,降低心智负担
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。