刚把训练脚本Ctrl+C终止,一跑nvidia-smi,显存还是满满当当地被吃着,新任务直接OOM。我相信每个做深度学习的人都被这个场景折磨过。更气人的是,你用ps aux | grep python查到一堆残留进程,kill -9打过去,PID倒是没了,显存依然一动不动。这篇文章就把这个坑彻底讲透:GPU显存为什么在进程死后不释放,怎么用ps、lsof这类命令把占用源揪出来,kill -2、kill -15、kill -9三种信号到底什么区别,以及怎么看一个进程是不是已经变成僵尸(defunct)了,遇到僵尸该怎么办。
这篇内容适合所有用GPU跑训练或推理的开发者,不管你是用PyTorch、TensorFlow还是自己撸CUDA,显存管理的问题早晚会碰到。把下面的排查逻辑和信令机制搞明白,下次遇到"显存莫名其妙少了几个G"或者"kill完进程还卡着显存",你就能自己动手解决,而不是靠重启机器续命。
1. GPU显存被占用的底层原因:先搞清楚谁是"凶手"
1.1 显存生命周期:进程退出后,显存到底由谁回收
很多人默认"进程死了,显存就自动释放",这句话在大多数情况下是成立的,但成立的前提是驱动正确地收到了进程退出的通知。GPU显存和CPU内存不一样,它的分配、释放由显存驱动栈统一管理。当你的Python进程正常退出或崩溃退出,内核里的GPU驱动模块会清理这个进程对应的地址空间,显存会被标记为可用。但这里有个致命前提:进程必须是真的"死透了"。
现实里经常出现的情况是,你Ctrl+C杀掉的是终端的前台进程,但训练脚本里用了multiprocessing或torch.distributed启动了多个子进程,这些子进程虽然由同一个父进程衍生,但它们各自持有独立的CUDA context。父进程挂了,子进程成了孤儿进程被init收养,如果子进程没有被正确终止,它们的显存占用就一直挂在那边。这就是为什么你杀了"主进程",显存纹丝不动——因为你杀的根本不是全部。
另一个隐蔽的情况是进程还活着,只是你没能找到它。用ps aux | grep python输出的结果里,真正需要关注的是第二列的PID和第八列的STAT状态。很多人在一个多机多卡的训练任务结束后,会残留大量训练进程,但他们的grep命令写得太宽松,匹配出了一堆自己的命令行,实际上的目标进程躲在更后面。
1.2 僵尸进程和显存占用的关系:别把脏水泼给僵尸
说到僵尸进程,很多人就直接联想到"杀不死、显存不释放",这个概念需要澄清。僵尸进程(zombie,STAT标记为Z)的本质是子进程已经退出,但父进程没有调用wait()系统调用来获取它的退出状态,所以内核保留了进程描述符。换句话说,僵尸进程本身是死的,它不占用CPU,不占用显存,也不占用内存——它只占用一个进程表项和一个PID。
所以如果你看到ps输出里有一个defunct或者标记为Z的进程,显存还占着,那一定另有原因。要么是还有别的活着的进程在占显存,要么是这个僵尸进程对应的真正GPU上下文没有释放,但这种情况极其罕见。更常见的坑是:你看到的僵尸进程是某个多进程训练框架的残留,而真正持有显存的worker进程还欢快地跑着,只是你没从ps的输出里识别出来。
1.3 CUDA context与显存镜像:为什么"进程没了"显存还在
再往深挖一层,驱动并不是简单地在进程退出时立刻把显存清空。现代GPU驱动为了提高效率,在进程内会缓存一些内存块,而CUDA runtime也在进程地址空间里维护着一批显存池。正常情况下,这些缓存会在进程退出时随上下文销毁。但如果你是在容器里跑训练,情况会更复杂——容器内的进程退出,容器本身还活着,GPU上下文可能还挂在容器对应的命名空间里。
还有一种场景是使用了CUDA Managed Memory或者多进程共享显存,导致同一块显存有多个引用计数。当一个进程退出时,因为它不是唯一的持有者,显存不会被立即释放,而另一个持有者如果一直不退出,显存就一直被占用。这一点和Linux文件删除后仍然占用磁盘空间的逻辑很像:文件在、inode在、只是目录项没了,只有所有fd关闭后空间才真正释放。
2. 三步锁定占用GPU的进程:从nvidia-smi到ps的全链路排查
2.1 第一步:用nvidia-smi看的是"当前会话"还是"所有进程"
排查显存占用,第一个命令永远是nvidia-smi。默认输出的底部Processes表格会列出当前使用GPU的进程,包含PID、进程名、显存占用。但这里有个细节:如果同一个GPU被多个用户或者多个容器共享,nvidia-smi默认可能只显示当前用户/当前容器的进程,你需要用sudo nvidia-smi才能看到所有进程,或者用nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv来拿到所有计算进程清单。
实际排查中,我建议直接这样:
nvidia-smi --query-compute-apps=pid,used_memory,process_name --format=csv输出会长这样:
pid, used_memory, process_name 12345, 8120MiB, python 12467, 8120MiB, python如果这里还有python进程在,那说明确实有残留。注意两列显存占用相同的现象很常见,尤其是用PyTorch DDP启动的多个进程,每个进程会各自预留一部分显存。这时候你要判断的是:这些PID到底是不是真的活着。
2.2 第二步:用ps aux加grep python看清进程状态和父子关系
拿到PID之后,下一步就是用ps去确认进程的真实状态。很多人直接ps aux | grep python,看到一堆结果就懵了。这里的关键在于理解ps输出每一列的含义:USER(哪个用户跑的)、PID(进程号)、PPID(父进程号)、%CPU、%MEM、VSZ、RSS、STAT(进程状态)、START、TIME、COMMAND。
排查时我习惯加一个完整格式:
ps -ef | grep python # 或 ps aux | grep python | grep -v grep注意那个grep -v grep是为了把自己这条grep命令行过滤掉。很多新手在ps输出里看到一个包含grep python的进程,还以为自己有什么残留程序。更推荐的做法是直接根据PID查询:
ps -o pid,ppid,stat,cmd -p PID这条命令会把某个具体PID的状态、父进程、完整命令行显示出来。STAT一列如果显示Z,那就是僵尸;如果显示D,代表不可中断的睡眠,这时候kill也杀不掉;如果显示S或R,说明进程还活着,可以正常kill。
2.3 第三步:用fuser和lsof找到GPU设备上的所有进程
ps只解决"看到进程"的问题,但要确认一个进程是否真的在使用GPU,更直接的方法是查GPU设备文件。NVIDIA驱动在/dev/nvidia*上创建设备节点,任何使用GPU的进程都会打开这些设备文件。所以fuser命令可以直接列出谁在占用设备:
sudo fuser -v /dev/nvidia*输出会列出每个设备文件对应的进程PID和用户。如果某个PID在fuser列表里但不在nvidia-smi的列表里,说明这个进程只是加载了驱动上下文,还没有实际申请显存。反过来,如果nvidia-smi里有显存占用但fuser里找不到,多半是容器或者权限隔离导致看不到。
lsof也可以用来补充查询:
sudo lsof /dev/nvidia*这条命令能看到哪个进程打开了NVIDIA设备文件,对于定位"到底是谁在偷偷用GPU"非常有用,特别是你怀疑有非python程序在占显存的时候。比如有些数据加载库会拉起多进程,这些worker进程名可能不叫python,而是叫别的可执行文件。
3. kill命令信号机制:kill -2、kill -15、kill -9到底干了什么
3.1 三个信号的底层逻辑:优雅退出和强制终止的区别
很多人把kill -9当成万能杀招,觉得信号越大越厉害,这是个误区。kill默认发的是SIGTERM(15号信号),意思就是"请终止",信号本身不含强制属性,进程可以自行决定是否处理。比如Python里注册了signal.signal(signal.SIGTERM, handler)就能在退出前做清理;而如果你没注册自定义handler,Python解释器默认的行为就是直接退出。
kill -2发的是SIGINT,也就是中断信号,和你在终端按Ctrl+C效果一样。它只是比SIGTERM更偏向"用户主动打断",很多程序会针对SIGINT做一些特殊处理,比如保存检查点、打印当前loss再退出。所以你在训练的时候按Ctrl+C,通常触发的是SIGINT,而用kill 默认触发SIGTERM。
kill -9发的是SIGKILL,相当于"立刻处决"。这个信号不能被进程捕获、不能被忽略、不能注册handler,内核直接把这进程从运行队列里摘掉,回收CPU和内存。注意"回收内存"说的是CPU内存,GPU显存由另一套驱动机制管理,SIGKILL之后驱动会收到进程释放事件,但清理动作可能异步进行,极端情况下接口卡住,显存就卡住了。
3.2 为什么不建议上来就kill -9
从GPU训练的角度讲,直接kill -9最大的问题是不会给进程任何清理机会。如果这个进程持有文件锁、写入训练日志或者正在保存模型权重,SIGKILL会把这些操作打断,留下一个写到一半的模型文件,下次训练加载时直接报错。更麻烦的是,如果它正在写TensorBoard的event文件,可能会留下损坏的日志,TensorBoard解析的时候会警告。
信号优先级应该是:先kill -2(SIGINT,相当于Ctrl+C)让程序保存现场,过几秒检查;不行就kill -15(SIGTERM)再给一次机会,让有handler的脚本做清理;最后才kill -9。给进程几秒钟的宽限期,比自己事后修补文件要省事得多。
这里有一个我自己的习惯:在写训练脚本时,会在signal.SIGTERM里注册一个钩子,把当前epoch的模型参数保存下来。这样无论是我自己kill还是别人kill,模型都不会丢。这个钩子在线上出故障的时候救命过好几次,后面细说。
3.3 kill -9之后显存还是没释放的几种真凶
用kill -9杀掉进程后,nvidia-smi还是显示显存占用,这时候需要冷静。大多数情况下真正的问题不是"显存没释放",而是"你没杀干净"。常见的原因有三种:
第一种是进程变成了僵尸。子进程已经退出,父进程没回收,所以你看到ps里还有一条僵尸记录,它的显存其实早就归还了,只是进程表里还挂着一个死壳。这种不用管,只需要处理父进程就行。
第二种是多进程残留。你kill了主进程,但子进程还活着,显存自然不释放。排查方法前面写过,fuser -v /dev/nvidia*能看到所有进程,一个个核对。
第三种是驱动本身卡住了。这种情况比较少见,通常伴随dmesg里大量NVRM相关报错。如果确认所有进程都没了、显存还不释放,可以试试重新初始化GPU:先卸载nvidia_uvm模块再加载,这条操作在服务器上影响面比较大,生产环境别乱试。
sudo rmmod nvidia_uvm sudo modprobe nvidia_uvm实测在驱动正常的情况下,这个操作可以清掉驱动层卡住的显存引用。
3.4 补充一个信号传递的坑:kill的其实是错误进程
ps aux | grep python输出的列表里,有时候你会看到PID很小的python进程,比如PID 1或者PID 7之类。这种是容器里的init进程,或者系统初始化脚本里拉起的常驻进程。盲目kill -9 PID 1,在某些容器环境里直接等于shutdown整个容器。所以在kill之前,务必用ps -o pid,ppid,cmd -p PID确认这个进程到底是什么来头。特别是看到PPID为0或者1的进程,除非你有十足把握,否则不要轻易动。
4. 僵尸进程完全解读:识别、产生原因与清理
4.1 僵尸进程的产生过程:为什么它会一直赖在ps里
用一个通俗的比喻:进程结束的时候,内核会给父进程发一个"SIGCHLD"通知,父进程接收到通知后,需要调用wait()来领取子进程的"死亡报告",然后内核才会把子进程的占位符从进程表里清掉。如果父进程自己很忙,一直没空调用wait(),或者父进程自己也挂了,但新的父进程(通常是init进程)没有及时接管,那个占位符就一直留着,这就是僵尸。
僵尸进程的STAT状态在ps里显示为Z,COMMAND尾常常跟着defunct标记。它不占CPU也不占内存,但会占一个PID。Linux的PID数量有上限(默认一般是32768或者更大),如果僵尸进程堆积,最终会导致系统无法创建新进程,这个后果在GPU服务器上很常见,因为训练脚本频繁拉子进程、父进程又没管理好。
确认一个进程是不是僵尸的最简单方法:
ps aux | grep -w Z # 或者 ps -ef | grep defunct | grep -v grep如果输出里有内容,那些就是僵尸。
4.2 为什么kill -9杀不掉僵尸进程
这是很多人最费解的地方:我一个kill -9打过去,为什么进程还在?原因很简单——僵尸进程已经死了。你能kill掉的只是活着的进程,僵尸进程已经处于退出状态,SIGKILL再暴力也只是对一个已经不存在的运行体发信号,不会有任何效果。
想要清掉僵尸进程,得收拾它的父进程。你有两个选择:要么把父进程杀掉,让僵尸进程被init进程收养,init会周期性调用wait()把它们收回;要么找到父进程后,给父进程发送SIGCHLD信号,让它去处理自己的子进程(但这个只对写了handler的程序有效,大多数Python脚本没有)。所以最有效的做法就是杀掉父进程:
ps -o ppid= -p 僵尸PID # 得到父进程PID后 kill -9 父进程PID注意:如果父进程本身是PID 1,那就是init进程,理论上init会自动回收僵尸,只是可能没那么及时。如果容器环境里PID 1是业务的init脚本,kill -9 PID 1会把整个容器搞挂,要小心。
4.3 从根源上减少僵尸进程:父进程的wait与管理
与其每次手动清僵尸,不如从代码层面减少僵尸的产生。在Python多进程训练中,最典型的问题是使用了multiprocessing.Pool或者concurrent.futures.ProcessPoolExecutor,但主进程异常退出,导致worker子进程变成孤儿。正确的做法是给主进程加超时管理和优雅退出逻辑。
另一个实际工程经验是使用torch.multiprocessing的时候,尽量用spawn启动方式,不要用fork。fork方式在子进程里如果继承了父进程已初始化的CUDA context,会导致显存分配异常。用spawn的方式会重新导入模块,每个子进程自己初始化CUDA,显存管理更干净,但也更慢。在显存灵敏的场景里,这个trade-off是值得的。
4.4 僵尸进程和显存泄漏:一次真实的排障过程
我之前遇到过一次很典型的案例:一个长期运行的推理服务,每天固定时间用multiprocessing启动一批worker,任务结束后workers正常退出,但服务进程没有写wait回收逻辑。连续跑了一周后,ps aux | grep defunct列出来一百多个僵尸进程,虽然显存没爆,但服务开始报"OSError: [Errno 11] Resource temporarily unavailable"。原因就是PID被僵尸占光了,新的worker拉不起来。
那次排障花了大半天,最后定位到问题就是父进程的join逻辑写错了位置。修好后,服务连续三个月没出过问题。所以遇到显存相关的问题,第一步看活进程,第二步看僵尸数量,两步分开排查,会少走很多弯路。
5. 实操:从发现显存占用到彻底清理的完整流程
5.1 一条龙排查命令
我把整个排查到清理的流程整理成一个可复制的操作序列,按顺序执行即可:
# 第1步:查看GPU整体占用情况 nvidia-smi # 第2步:列出GPU上的所有计算进程 nvidia-smi --query-compute-apps=pid,used_memory,process_name --format=csv # 第3步:查看GPU设备上的所有文件持有者 sudo fuser -v /dev/nvidia* # 第4步:根据第2步拿到的PID,确认进程状态、父进程、命令行 ps -o pid,ppid,stat,etime,cmd -p PID # 第5步:确认其中没有需要保留的,按顺序kill # 先给一个SIGTERM,等5秒 kill -15 PID sleep 5 # 还没死再给SIGKILL kill -9 PID # 第6步:看看有没有僵尸进程 ps -ef | grep defunct | grep -v grep # 第7步:有僵尸就看父进程是谁 ps -o pid,ppid,stat,cmd -p 僵尸PID # 第8步:杀掉僵尸的父进程(确认父进程可杀后) kill -9 父PID这套流程跑完,90%的显存残留问题都能解决。剩下没解决的,多半是驱动级问题,重启或者重装驱动。
5.2 常见问题与排查速查表
为了节省时间,我整理了一张速查表,可以直接对照症状找方案:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| nvidia-smi显示大量python进程占用显存 | 训练进程残留 | 逐个ps核对后kill -15再kill -9 |
| ps里能看到PID但kill后仍在 | 进程处于D状态 | 等待或排查IO问题,必要时重启 |
| ps里显示Z和defunct | 僵尸进程 | 杀掉其父进程;确认父进程可杀再操作 |
| kill -9后显存仍然不释放 | 进程没杀干净或驱动卡住 | fuser核对残留,rmmod/modprobe nvidia_uvm |
| 容器内杀进程导致整个容器退出 | 误杀PID 1 | 新容器尽量用tini或s6作为init进程 |
| 显存使用量持续缓慢增长 | 代码显存泄漏 | 用pytorch的显存快照或torch.cuda.memory_summary排查 |
5.3 防患于未然:显存使用的好习惯
最后说几个我认为值得养成的习惯。第一,训练脚本的入口加上信号处理逻辑,让SIGTERM也能触发模型保存和进程优雅退出;第二,使用torch.cuda.empty_cache()的场景要理解清楚,它只是释放CUDA缓存,不是真正的进程退出的显存回收,不要指望它解决进程残留;第三,给每个训练任务设置环境变量CUDA_VISIBLE_DEVICES,这样即使进程残留,也至少能隔离到特定显卡,不会影响其他任务。
我现在每次排查显存问题,都会在nvidia-smi之后顺手看一眼ps -ef | grep defunct,这个习惯帮我提前避免了好几次PID耗尽的事故。
5.4 一条实用的清理技巧:按条件批量kill时先print再执行
批量kill残留进程的诱惑很大,比如经常有人网上找一条命令:
ps aux | grep python | awk '{print $2}' | xargs kill -9我强烈不建议直接这么干。这条命令会把你自己当前跑的脚本也杀了(如果它也是python),还会误伤系统里其他用户的python服务。如果非要批量,必须先把进程列出来看清楚,确认哪些可以杀,再写进命令。我习惯的做法是先把PID输出到文件里检查一遍:
ps -eo pid,user,cmd | grep -E 'train.py|inference.py' | grep -v grep > /tmp/gpu_procs.txt cat /tmp/gpu_procs.txt # 人工确认后 awk '{print $1}' /tmp/gpu_procs.txt | xargs -r kill -15多花两分钟做安全确认,比起误杀后重新拉起服务省太多时间。
6. 遇到驱动级显存泄漏:最后的手段与工具
6.1 显存泄漏的复现与定位思路
如果代码有显存泄漏,你会看到显存占用在多个epoch后不断上涨,最终OOM。这跟进程残留是两码事。定位代码级泄漏,优先用PyTorch自带的工具:
import torch print(torch.cuda.memory_summary())memory_summary会把缓存块、活跃块、保留内存池都列出来。如果发现某个Tensor的size持续不变但显存一路涨,优先怀疑loss或者中间变量没有释放。再进阶一点,用torch.cuda.set_per_process_memory_fraction设置显存上限,让泄漏程序在达到上限时直接报错,而不是拖垮整机。
6.2 重建GPU环境的兜底方案
当你确认所有进程都清了、僵尸也没了,驱动层也重刷过,显存还是被占着,最后的手段就是重置GPU。如果是NVIDIA Tesla卡,有些型号支持GPU复位:
sudo nvidia-smi --gpu-reset -i 显卡编号如果这个命令报错不支持,那就只能重启机器了。实际上,如果一台GPU服务器出现反复的显存不释放,说明驱动和CUDA环境可能已经不稳定,这时候备份数据和环境配置,重装驱动往往比反复排查更高效。
我在生产环境里遇到过一次驱动卡死,所有进程都杀了、显存还是显示占用12G,最后重启解决。重启之后把驱动从535升到了550,这个问题再也没出现过——所以有些时候该升级驱动就升级,别老是在应用层打转。
6.3 AI框架内的显存复用机制与进程级隔离
稍微聊深一层,很多AI框架内部是有显存复用机制的。PyTorch的CachingAllocator会缓存释放的显存块,而不是立刻还给驱动。这就是为什么你在一个长生命周期进程里反复创建和销毁Tensor,nvidia-smi里看到的显存使用量只升不降——因为缓存不释放。这个特性本身是为了加速,但容易让人误判为泄漏。
要验证是不是框架缓存导致的显存虚高,可以用:
torch.cuda.empty_cache()看nvidia-smi显存是否下降。下降说明是缓存,不下降说明有Tensor引用没释放。容器场景下,如果多个任务共享一张卡,建议每个任务限制显存上限,避免互相影响:
export PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128这个参数能把显存碎片化问题压下去一些,实测在频繁创建小Tensor的任务里效果明显。关于显存碎片,我踩过不少坑,后来统一在训练脚本开头设置好PYTORCH_CUDA_ALLOC_CONF,OOM的概率降了一大截。
整个排查显存问题的过程,说穿了就三件事:确认活进程、清理僵尸、理解信号机制。ps aux | grep python只是第一层,真正的功夫在判断每个进程该不该杀、怎么杀、杀了之后有没有副作用。把第一张速查表打印出来贴在工位上,踩坑的时候翻一下,比想象中有用。