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 一张图看懂高并发碎片特征

下面这张图总结了三种高并发场景的碎片特征和应对策略:

高并发场景显存碎片特征分析 多线程/多进程共享GPU • 进程间context隔离 • 分配释放不同步 • 内存池竞争激烈 碎片率:30%-50% 建议:单进程多线程替代 动态batch与变长输入 • 分配粒度不匹配 • 频繁的大小切换 • 大块被切碎难恢复 碎片率:40%-60% 建议:固定档位预分配 模型并行与数据并行 • 数据并行:碎片集中 • 模型并行:碎片分散 • 混合并行:叠加效应 碎片率:10%-60% 建议:按层独立管理池 核心结论 1. 高并发场景下,显存碎片是必然的,但可以管理 2. 不同场景的碎片特征不同,需要针对性优化 3. 预分配 + 内存池 + 固定粒度 = 碎片克星 4. 没有银弹,需要根据实际场景组合使用

嗯,这张图基本把三种场景的碎片特征和应对策略都概括了。我个人觉得,理解这些差异比背一堆优化技巧更重要。因为只有理解了「为什么碎」,才能知道「怎么治」。

一个小技巧:如果你不确定自己的场景属于哪一类,可以先用cudaMemGetInfo和cudaDeviceGetMemPool接口,每隔几秒采样一次显存分配情况。画个时间序列图,碎片特征一目了然。我每次排查碎片问题,第一件事就是画这个图。

好了,这一章就到这里。记住:高并发场景下的显存碎片,不是「有没有」的问题,而是「怎么管」的问题。下一章我们会聊聊具体的碎片检测和定位工具,到时候我会分享一些实战中踩过的坑。

可可精品资料库