4、高并发场景特征分析:多线程/多进程共享GPU、动态batch与变长输入、模型并行与数据并行的碎片差异
好,咱们进入第四章。这一章我打算聊聊高并发场景下的显存碎片问题。说实话,很多同学在单卡单进程上跑模型时,根本感觉不到碎片的存在。但一旦上了生产环境,多线程、多进程一拥而上,显存碎片就会像幽灵一样冒出来。
我个人习惯把高并发场景分成三类来讨论:多线程/多进程共享GPU、动态batch与变长输入、以及模型并行与数据并行。这三类场景的碎片特征完全不同,处理方式也各有侧重。
4.1 多线程/多进程共享GPU:谁动了我的显存?
先说说多线程/多进程共享GPU的情况。这是最典型的碎片制造机。
你想想看,多个进程同时往同一块GPU上分配显存,每个进程都有自己的生命周期。有的进程刚启动,正在疯狂分配;有的进程快结束了,正在释放。这种「你方唱罢我登场」的局面,最容易产生外部碎片。
我在项目中遇到过这样一个案例:一个推理服务部署了8个worker进程,每个worker独立管理自己的CUDA context。刚开始跑得好好的,但运行了大概12小时后,突然有个worker报OOM。我检查了一下,发现其他worker的显存占用都很正常,唯独这个worker的显存被「吃」掉了将近2GB。后来用nvidia-smi一看,显存分配量确实不大,但碎片率高达40%。
核心问题:多进程共享GPU时,每个进程的CUDA context会占用一部分固定显存。更麻烦的是,进程间的分配和释放节奏不同步,导致显存被切割成大量小块。
为什么会这样?我解释一下:
- 进程间隔离:每个进程有自己的CUDA context,context本身就要占几十MB。8个进程就是几百MB的固定开销。
- 分配释放不同步:进程A分配了1GB,进程B分配了500MB,进程A释放了,但进程B还占着。这时候新来的进程C想分配800MB,发现连续空间不够了。
- 内存池竞争:CUDA driver内部有一个全局的内存池,多进程同时操作这个池子,会产生大量的锁竞争和碎片。
我的建议:如果可能,尽量用单进程多线程替代多进程。单进程内,所有线程共享同一个CUDA context,内存池是统一的,碎片率会低很多。我曾经把一个8进程的服务改成8线程,显存碎片率从35%降到了8%。
4.2 动态batch与变长输入:碎片制造机中的战斗机
接下来聊聊动态batch和变长输入。这个场景,说白了就是「每次请求的显存需求都不一样」。
你想想看,一个NLP服务,有的请求只有10个token,有的请求有512个token。如果每次都按最大长度分配,那肯定浪费。但如果按实际长度分配,那每次分配的大小都不一样,碎片就来了。
我记得有一次做BERT推理优化,用户输入的句子长度从5到128不等。我一开始天真地用了动态分配,结果跑了一天,显存碎片率飙到了60%。后来我改用固定大小的预分配池,虽然浪费了一点显存,但碎片率降到了5%以下。
这里有个关键点:变长输入导致的碎片,本质上是「分配粒度不匹配」。每次分配的大小都不一样,释放后留下的空洞大小也不一样。新来的分配请求很难恰好填满这些空洞。
避坑指南:我曾经在动态batch场景下踩过一个坑——用cudaMallocAsync配合流式分配。本以为能自动管理碎片,结果发现频繁的流切换反而加剧了碎片。后来我改用cudaMemPool手动管理,才稳定下来。
动态batch还有一个隐藏问题:batch size的波动。比如你的服务支持batch size从1到32动态调整。当batch size从32降到1时,释放的显存块很大,但后续的请求可能只需要小块的显存。大块被切碎后,再也拼不回去了。
我建议的做法是:
- 固定几个batch size档位:比如1、4、8、16、32,每个档位预分配固定大小的内存池。
- 请求按档位路由:batch size在1-4的走4的池子,5-8的走8的池子,以此类推。
- 池子之间不混用:每个池子独立管理,避免跨池子的碎片传递。
4.3 模型并行与数据并行的碎片差异:两种不同的痛
最后聊聊模型并行和数据并行。这两种并行方式,碎片特征完全不同。
数据并行:每个GPU上放一份完整的模型,数据被切分到各个GPU上。这种情况下,每个GPU的显存分配模式是相同的——模型参数占固定大小,梯度占固定大小,只有输入数据是变化的。所以碎片主要来自输入数据的动态变化。
模型并行:模型被切分到多个GPU上,每个GPU只放一部分。这种情况下,每个GPU上的显存分配模式完全不同。有的GPU负责embedding层,分配的是大块连续显存;有的GPU负责attention层,分配的是大量小块显存。
我在项目中遇到过这样一个对比:
| 并行方式 | 碎片特征 | 主要碎片来源 | 碎片率(实测) |
|---|---|---|---|
| 数据并行 | 碎片集中在输入数据区 | 动态batch、变长输入 | 10%-20% |
| 模型并行 | 碎片分散在各层之间 | 不同层的分配粒度差异 | 20%-40% |
| 混合并行 | 碎片最严重 | 上述两者叠加 | 30%-60% |
为什么会这样?我解释一下:
- 数据并行:每个GPU的分配模式高度一致,碎片主要来自输入数据的动态变化。而且因为模型参数是固定的,所以碎片集中在「数据区」这一小块区域。
- 模型并行:每个GPU上的分配模式完全不同。比如负责embedding的GPU,分配的是大块连续显存;负责attention的GPU,分配的是大量小块显存。不同层之间的分配粒度差异,导致碎片更容易产生。
- 混合并行:既有多GPU间的数据切分,又有单GPU内的模型切分。碎片来源叠加,情况最复杂。
关键结论:模型并行比数据并行更容易产生碎片。如果你在做模型并行,建议每层单独管理内存池,避免不同层的分配模式互相干扰。
4.4 一张图看懂高并发碎片特征
下面这张图总结了三种高并发场景的碎片特征和应对策略:
嗯,这张图基本把三种场景的碎片特征和应对策略都概括了。我个人觉得,理解这些差异比背一堆优化技巧更重要。因为只有理解了「为什么碎」,才能知道「怎么治」。
一个小技巧:如果你不确定自己的场景属于哪一类,可以先用cudaMemGetInfo和cudaDeviceGetMemPool接口,每隔几秒采样一次显存分配情况。画个时间序列图,碎片特征一目了然。我每次排查碎片问题,第一件事就是画这个图。
好了,这一章就到这里。记住:高并发场景下的显存碎片,不是「有没有」的问题,而是「怎么管」的问题。下一章我们会聊聊具体的碎片检测和定位工具,到时候我会分享一些实战中踩过的坑。