IT加油站

LLM 流水线气泡管理:你花钱买的 GPU 正在空转

68浏览 • 10天前 • 软件教程 • MA123741

原文:https://dev.to/shrsv/pipeline-bubble-management-for-llms-the-gpus-you-paid-for-are-waiting-28hg(作者 @shrsv)

你可能坐拥八块昂贵的 GPU、一个充分优化的模型、高速网络、FlashAttention、连续批处理(continuous batching),再加上一套精心调校过的运行时。

却仍然把机器的很大一部分算力浪费在彻头彻尾的空转上。

罪魁祸首往往是流水线气泡(pipeline bubble)。

流水线气泡,是流水线各阶段之间的依赖关系造成的闲置容量。一块 GPU 已经准备好干活,但它需要的数据还没送到;又或者分配给某个阶段的工作已经做完,这个阶段只能干等着。

这是一类随着 LLM 规模变大而愈发重要的系统问题。

有意思的是,解决方案很少是"买更快的 GPU"。

解决方案通常是:

让流水线始终保持满载。

1. 直觉理解:像工厂一样思考

想象一家有四名工人的工厂:

Worker 1 -> Worker 2 -> Worker 3 -> Worker 4


每件产品都必须经过这四名工人之手。

如果只有一件产品:

Time --->

W1: [A]
W2:     [A]
W3:         [A]
W4:             [A]


三名工人大部分时间都在等待。

现在把工作拆成若干个小批次:

Time --->

W1: [A] [B] [C] [D] [E]
W2:     [A] [B] [C] [D] [E]
W3:         [A] [B] [C] [D] [E]
W4:             [A] [B] [C] [D] [E]


工人们就可以并发干活了。

工厂执行单个操作的速度并没有变快。

它变强的地方在于让操作相互重叠。

这正是深度学习中流水线并行(pipeline parallelism)背后的核心思想。

这段历史脉络值得记住。GPipe 由 Google 的 Yanping Huang 及其同事发表在 NeurIPS 2019 上,它通过把批次切分成微批次(micro-batch),再依次推入网络的不同分区,让流水线并行在超大规模神经网络上变得切实可行。他们的演示包括一个 60 亿参数、128 层的 Transformer,训练覆盖 100 多种语言。([Google Research][1])

问题在于,即便是流水线,也有启动和收尾的开销。

这些开销就是气泡。

2. 气泡从何而来

假设你有:

p = 4 pipeline stages
m = 4 micro-batches


暂且忽略通信开销,一个简化后的调度示意如下:

             Time
Stage 1:     A A A A
Stage 2:       A A A A
Stage 3:         A A A A
Stage 4:           A A A A


一开始,只有阶段 1 有活可干。

接着阶段 2 苏醒。

然后是阶段 3。

再然后是阶段 4。

这段启动期就是填充(fill)。

结束时则发生相反的过程。工作从流水线中逐渐排空,各阶段相继闲置。

对于经典的 GPipe 式调度,气泡占比有一个很好用的近似公式:

bubble_fraction = (p - 1) / (m + p - 1)


其中:

p = number of pipeline stages
m = number of micro-batches


假设你用 8 块 GPU 和 8 个微批次:

bubble = (8 - 1) / (8 + 8 - 1)
       = 7 / 15
       = 46.7%


这样的利用率堪称灾难。

把微批次增加到 64:

bubble = 7 / (64 + 8 - 1)
       = 7 / 71
       = 9.9%


GPU 并没有变快。

你只是给了流水线更多可以相互重叠的工作。([arXiv][2])

由此可以得到一条实用的工程法则:

micro-batches >> pipeline stages


一个有 32 个阶段却只有 8 个微批次的流水线,在结构上就很难保持忙碌。

而一个 8 个阶段、128 个微批次的流水线,可重叠的机会要多得多。

3. LLM 让问题变得更有意思

LLM 推理引入了另一个不均衡来源。

一个 LLM 请求包含两个本质上截然不同的阶段:

Prefill -> Decode -> Decode -> Decode -> ...


Prefill(预填充)

模型会一次性消费整个输入提示词。

对于一个 4000 token 的提示词,它可能并行处理数千个 token。

这个阶段计算密集,通常能高效利用 GPU。

Decode(解码)

模型每次只生成一个新 token。

因此一个请求的行为大致如下:

Prefill:  4000 tokens
Decode:   1 token
Decode:   1 token
Decode:   1 token
...


两个阶段的硬件特性因此完全不同。

Prefill 偏向计算密集型。

Decode 则往往受内存搬运和 KV cache 访问的制约,算术强度可能低得多。

现在想象把这些请求放进流水线。

某个 micro-batch 可能装着一次大规模 prefill:

████████████████████


另一个可能只包含几个 decode 步骤:

██


各个 stage 的工作量不再相等。

于是会出现第二种气泡:

Stage 1: ████████████████████
Stage 2: ████████████
Stage 3: █████
Stage 4: ███


从技术上讲,流水线里确实塞满了请求。

它只不过是负载严重失衡而已。

这正是 SARATHI 要解决的问题。SARATHI 是 2023 年由微软印度研究院与佐治亚理工学院的 Amey Agrawal、Ashish Panwar、Jayashree Mohan 及其同事提出的系统。它没有把 prefill 和 decode 当作互不相关的工作负载,而是将大的 prefill 切分成块,并把一个 prefill 块与多个 decode 请求组合在一起。核心思想是让连续多个 micro-batch 的工作量更加均匀。([arXiv][3])

他们的实验很好地提醒我们:“更多批处理”这种说法太含糊了。

真正重要的问题是:

批次的构成应该是什么样的?

4. 第一个真正的杠杆:micro-batch 大小

假设模型让一个 micro-batch 通过一个流水线 stage 大约需要:

T = 10 ms


同时你有:

p = 8 stages
m = 8 micro-batches


有效工作量大约是:

8 micro-batches * 10 ms
= 80 ms


但流水线需要先填充再排空。

理想情况下的总耗时正比于:

m + p - 1
= 8 + 8 - 1
= 15 time units


如果改成:

m = 64

m + p - 1
= 64 + 8 - 1
= 71


问题就会大幅缩小。

填充成本并没有变。

你只是把它摊销到了更多的有效工作上。

这与许多排队系统背后的基本原理相同:

fixed overhead / amount of work


随着有效工作量增加,这个比值会变小。

但这里有个坑。

Micro-batch 会消耗内存。

在执行期间,对于仍在流水线上处理中的工作,中间激活值或 KV cache 状态必须一直驻留在内存里。

于是我们遇到了一个系统层面的权衡:

more micro-batches
        |
        +--> smaller bubbles
        |
        +--> more memory pressure


而且还有另一个问题:

smaller micro-batches
        |
        +--> worse GPU kernel efficiency


GPU 通常需要足够多的工作量,才能形成大型、高效的矩阵运算。

所以工程目标不是:

maximize micro-batches


而是:

maximize useful overlap
subject to
memory + kernel-efficiency + latency constraints


5. 第二个杠杆:让各 stage 负载均衡

想象四块 GPU 分别承担以下负载:

GPU 0: 100 units
GPU 1: 100 units
GPU 2: 100 units
GPU 3: 160 units


整条流水线的吞吐量由 GPU 3 决定。

其余 GPU 会不断重复这样的状态:

done
waiting
done
waiting
done
waiting


这相当于你买了一台 160 单位的机器,又给它外挂了三台 100 单位的机器。

这正是流水线切分(partitioning)重要的原因。

如果工作可以重新分配:

Before:

100 | 100 | 100 | 160

After:

115 | 115 | 115 | 115


最慢的 stage 就从 160 降到了 115。

整条流水线都会因此受益。

用数学语言来说,稳态吞吐量的一个简单近似是:

throughput ~= 1 / max(T1, T2, ..., Tp)


其中 Ti 是第 i 个 stage 的执行时间。

这个 max() 很关键。

流水线不在乎平均每个 stage 耗时 112 ms。

它在乎的是最慢的 stage 耗时 160 ms。

因此 DeepSpeed 提供了显式的机制来跨流水线 stage 切分模型,包括基于参数量和基于层数的切分方式。它的文档也直接点明了实操要点:流水线性能高度依赖负载均衡。([DeepSpeed][4])

对 LLM 而言这一点尤其关键,因为在真实系统里各层并不总是完全相同的。

不同层可能有不同的内存行为。

注意力密集的组件与 MLP 密集的组件表现可能不同。

通信开销也会因层的放置位置而异。

一个按参数量看很均衡的切分方案,按实际运行时间衡量可能仍然不均衡。

正确的衡量指标通常是:

wall-clock stage time


而不是:

number of layers


6. 第三个杠杆:改变调度

流水线系统的历史中还藏着另一条洞见。

最朴素的策略是:

前向计算所有 micro-batch
反向计算所有 micro-batch


GPipe 的 fill-drain(填充-排空)方案就是这么工作的。

另一种做法是把前向和反向操作交错起来:

1F1B

1 次前向
1 次反向
1 次前向
1 次反向
...


PipeDream 与 Aaron Harlap、Deepak Narayanan、Amar Phanishayee、Vivek Seshadri 等人相关,它在分布式 DNN 训练中探索了这种批间流水线方式。该系统明确以更好的重叠和更高的加速器利用率为目标。([arXiv][5])

这里值得吸取的系统级经验,比具体算法本身更宽泛:

调度本身就是一种资源分配策略。

想一想:

GPU 0:
F1 F2 F3 F4 F5 F6

GPU 1:
   F1 F2 F3 F4 F5 F6

GPU 2:
      F1 F2 F3 F4 F5 F6


再对比一个只要依赖关系允许、就立刻启动反向计算的调度方案。

你改变的是:内存在何时分配、通信在何时发生、以及每块 GPU 在何时空闲下来。

在推理系统中,同一个思想以不同的形式出现。

你可能有:

prefill 队列
decode 队列
等待中的请求
KV-cache 内存
GPU 算力


由调度器决定下一项进入流水线的工作是什么。

这让调度变成了一个优化问题。

一个粗糙的目标函数大概长这样:

最大化:

GPU 利用率
+ 吞吐量
- 延迟惩罚
- 内存压力
- 调度开销


真实系统要复杂得多,但这个心智模型很有用。

7. 经济学视角:流水线气泡就是闲置的现金

假设你租用了:

8 块 GPU
每块 $3/小时


你的基础设施成本是:

8 * $3 = $24/小时


现在假设你的有效利用率是 60%。

粗略地说,你付出的是:

$24/小时


换来的却更接近:

8 * 0.60 = 4.8 块满负荷 GPU 等效


也就是说:

每块满负荷 GPU 的有效成本
= $24 / 4.8
= $5/小时


在不新购任何东西的前提下,把利用率从 60% 提升到 80%:

8 * 0.80 = 6.4 块满负荷 GPU 等效


现在:

$24 / 6.4
= $3.75/小时


你实际上把每单位有效算力的基础设施成本降低了大约:

1 - 3.75/5
= 25%


这就是为什么流水线气泡管理不仅是一个 GPU 编程问题,同样是一个经济学问题。

同样的推理也适用于延迟。

设想一个这样的服务:

GPU 计算 = 30 ms
流水线等待 = 20 ms
网络 = 5 ms
排队 = 10 ms


用户实际体验到的大约是:

65 ms


而其中只有 30 ms 是真正的模型计算。

把矩阵乘法提速 10%,得到的是:

30 -> 27 ms

总计:
27 + 20 + 5 + 10
= 62 ms


消除 20 ms 的流水线气泡,得到的是:

30 + 0 + 5 + 10
= 45 ms


效果更大的那项优化,不是动 GPU kernel 的那个。

而是动 GPU kernel 周围的系统 的那个。

这才是流水线管理更深层的一课。

当你的 LLM 系统逐渐变大时,先问:

工作在哪里等待?


再去问:

我怎样才能让计算更快?


8. 实战中如何调试流水线气泡

最容易犯的错误,是只看 GPU 的整体利用率。

假设你的监控面板显示:

GPU 利用率:72%


这并不能告诉你是否存在流水线问题。

你需要的是一条时间线。

例如:

GPU 0: ███████████████████████████████
GPU 1:   █████████████████████████████
GPU 2:       █████████████████████████
GPU 3:           █████████████████████


对比:

GPU 0: ███████████████████████████████
GPU 1: ███████████████████████████████
GPU 2: ███████████████████████████████
GPU 3: ███████████████████████████████


第二种才是你想要的。

对一个真实的 LLM 服务,至少要对以下环节埋点:

请求到达
队列等待
prefill 开始/结束
decode 开始/结束
stage 开始/结束
通信开始/结束
KV-cache 分配
KV-cache 驱逐
请求完成


然后计算:

stage_utilization_i
= busy_time_i / wall_clock_time


以及:

pipeline_efficiency
= useful_work / total_pipeline_work


还要测量各 stage 执行时间的波动程度:

CV = standard_deviation(stage_time) / mean(stage_time)


变异系数(CV)偏高往往是一个警示:名义上均衡的负载,实际上并不均衡。

对推理来说,prefill 和 decode 要分开测量。

一个服务可能整体吞吐量漂亮,尾延迟却很糟糕,因为长 prefill 会一次次打断 decode。

这正是促使 SARATHI 尝试让 micro-batch 更均匀的那种负载间相互作用。([Microsoft][6])

9. 值得记住的心智模型

真正需要调节的旋钮其实只有四个:

              Pipeline Performance
                       |
        +--------------+--------------+
        |              |              |
   Micro-batches   Stage balance   Schedule
        |              |              |
   fill/drain       bottleneck      ordering
        |
     Memory


而对现代 LLM 推理来说,还有第五个:

Work composition

prefill <-> decode


核心方程简单得近乎令人尴尬:

pipeline throughput ~= 1 / slowest_stage_time


而核心直觉还要更简单:

idle hardware is lost capacity


这就是为什么流水线气泡管理值得被看作 LLM 工程的一门一等公民学科。

LLM 著名的扩展(scaling)叙事通常围绕 FLOPs、参数量、内存带宽、attention 内核、量化和互连展开。

这些当然重要。

但一旦你拥有一个足够大的分布式系统,另一个问题就变得同样重要:

每一时刻,这台机器到底有多少比例在做有用的工作?

GPipe 证明了微批处理(micro-batching)能让超大模型变得切实可行;PipeDream 展示了调度与流水线执行如何让分布式加速器保持高效产出;SARATHI 则表明,在 LLM 推理中,连微批次的构成都能决定气泡会膨胀到多大。([Google Research][1])

因此,现代 LLM 工程师面对的优化问题略有不同。

不再是:

"How fast is my GPU?"


而是:

"For every millisecond of my GPU bill,
how many milliseconds contain useful work?"


这就是流水线气泡管理。

你在实践中遇到过哪种流水线瓶颈:微批次气泡、各阶段负载不均、prefill/decode 相互干扰,还是通信停顿?

[1]: https://research.google/pubs/gpipe-efficient-training-of-giant-neural-networks-using-pipeline-parallelism/ "GPipe: Efficient Training of Giant Neural Networks using Pipeline Parallelism"

[2]: https://arxiv.org/abs/1811.06965 "GPipe: Efficient Training of Giant Neural Networks using Pipeline Parallelism"

[3]: https://arxiv.org/abs/2308.16369 "SARATHI: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills"

[4]: https://www.deepspeed.ai/tutorials/pipeline/ "Pipeline Parallelism - DeepSpeed"

[5]: https://arxiv.org/abs/1806.03377 "PipeDream: Fast and Efficient Pipeline Parallel DNN Training"

[6]: https://www.microsoft.com/en-us/research/publication/sarathi-efficient-llm-inference-by-piggybacking-decodes-with-chunked-prefills/?msockid=1115aefb038768df34d0b8ae025b690c&utm_source=chatgpt.com "SARATHI: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills - Microsoft Research"

原文:https://dev.to/shrsv/pipeline-bubble-management-for-llms-the-gpus-you-paid-for-are-waiting-28hg(作者 @shrsv)

#LLM #GPU利用率 #流水线并行 #流水线气泡 #推理优化
1