diff --git a/docs/chapter9/chapter9_0.md b/docs/chapter9/chapter9_0.md new file mode 100644 index 0000000..6ae78a5 --- /dev/null +++ b/docs/chapter9/chapter9_0.md @@ -0,0 +1,164 @@ +# 9. 并发 + +  我们日常使用 ChatGPT 等大语言模型(LLM)应用来提升工作效率,或者通过模型厂商提供的 API 来开发项目。那么,这些服务是如何确保在生产环境中应对每分钟数万次乃至更多请求的同时,还能为全球用户提供始终如一的良好体验呢?这离不开先进的**并发处理技术**的支持。 + +## 1. 推理过程 + +  LLM 推理分为两部分: 预填充阶段(Prefill) 和生成阶段(Generation)。 + +### 1.1 预填充阶段 + +  在预填充阶段所做的事有:处理输入 prompt 的所有 tokens 并行计算所有输入 tokens 的 attention,生成并缓存 Key-Value(KV cache)。通常耗时较长,但只需执行一次。 + +### 1.2 生成阶段 + +  该阶段则是自回归生成每个新 token,使用之前生成的 KV cache,只需计算最新 token 的 attention。每个 token 耗时较短,但由于 Transformer 的自回归特性需要串行执行。 + +  模型从输入序列(例如 “Artificial Intelligence is”)开始,通过多层网络计算生成下一个单词(如 “the”)。每次生成一个新单词后,将其加入到输入序列中,作为下一次推理的输入。这个循环过程一直持续,直到达到预设的最大长度(如 2048 tokens)或生成特定结束标记(如 ``)。 + +![](./images/llm-inference.png) + +  由于 Transformer 的自回归特性,其推理是逐步的,每一步都依赖上下文和历史生成结果。因此还需要先前所有 tokens 的表示。 + +## 2. KV-Cache + +在训练过程中,Attention 机制会计算查询(Query)、键(Key)和值(Value)矩阵的所有元素之间的关系。这意味着模型会使用**完整的 QKV 矩阵** 来计算注意力分数和加权和,从而生成所有可能的 next token。 + +![](./images/kv-cache.png) + +而在推理过程中我们只关心预测 next token,为了提高效率,只需要计算当前最尾的一个查询向量(Q[-1])与所有的键向量(K[:])和值向量(V[:])之间的关系。通过计算好的 k 和 v 值,我们可以用空间换时间。 + +无 kv-cache 时, + +```python +idx = cat(idx, next_idx) +``` + +开启 kv-cache 后, + +```python +idx = next_idx +``` + +![](./images/kv.png) + +更详细的实现如下: + +```python +# 训练时预分配 cache 空间 +self.cached_keys = torch.zeros( + (max_batch_size, max_sequence_length, num_attention_heads, attention_dim) +) +self.cached_values = torch.zeros( + (max_batch_size, max_sequence_length, num_attention_heads, attention_dim) +) + +# 推理时在 forward 中: +# 1. 计算当前输入的 QKV +query = self.query_proj(x).view(batch_size, seq_length, num_heads, attention_dim) +key = self.key_proj(x).view(batch_size, seq_length, num_heads, attention_dim) +value = self.value_proj(x).view(batch_size, seq_length, num_heads, attention_dim) + +# 2. cache +if using_cache and not self.training: + # 将新计算的 key,value 存入 cache 对应位置 + self.cached_keys[:batch_size, start_position:start_position + seq_length] = key + self.cached_values[:batch_size, start_position:start_position + seq_length] = value + + # 获取包含历史和当前的完整 key,value 序列 + key = self.cached_keys[:batch_size, :start_position + seq_length] + value = self.cached_values[:batch_size, :start_position + seq_length] +``` + +因此,高效管理 KV-Cache 是实现高吞吐量部署服务的关键,我们会在 **9.2 节** 中详细讨论。 + +## 3. 重要指标 + +  为了评估 LLM 的并发推理能力,我们最感兴趣的指标是**延迟**(latency)和**吞吐量**(throughput)。 + +### 3.1 延迟 + +  延迟是评价 LLM 对用户查询给出反馈速度的标尺,塑造了用户对生成型 AI 应用的直观体验。因此在即时交互场景中,低延迟极为关键。为了全面评估模型延迟,可采纳以下几种度量方式: + +#### 3.1.1 **TTFT**(Time to First Token) + +  即从请求发出到接收首个回答 token 的时间间隔。 + +  影响 TFTT 的主要因素有: + +- **网络速度**:取决于系统的带宽以及网络请求在推理时是否拥挤。 +- **输入序列长度**:提示(prompt)越长,则模型在输出第一个令牌之前需要更多处理。 +- **模型大小**:直观上,模型参数量越大,则执行计算以生成响应会增加,并导致 TFTT 变长。 + +  这一指标在“在线流式输出模式”下尤为重要,因为它直接影响用户感知的响应速度。 + +#### 3.1.2 **TPOT**(Time per Output Token) + +  即除了首个 token 外,输出每个 token 的平均时长。 + +  较短的 TPOT 可以提高系统的整体响应速度,特别是在需要生成大量文本的情况下,如离线批处理服务。 + +#### 3.1.3 总体延迟 + +  指的是模型的**端到端延迟**:从用户最初输入提示到接收到模型完成的输出之间的时间跨度。 + +  通常我们说的延迟,实际上指的就是这个指标。其计算方式如下: + +$$总体延迟 = TTFT + (TPOT \times 要生成的\:token\:数量)$$ + +  从公式中可以看出,影响 TFTT 的主要因素有: + +- 输出长度:最重要的影响因素,因为它直接决定了 TPOT 部分的大小。输出越长,即需要生成的 token 数量越多,延迟时间也会相应增加。 + +- 预填充时间:对应 TTFT。 + +- 排队时间:由于硬件限制——尤其是 GPU 显存不足时,LLM 可能无法跟上用户的请求速度。这意味着部分输入请求需要先排队等候处理。这也正是 TTFT 成为一项普遍记录指标的原因所在,因为它能揭示模型服务器应对不同数量用户请求的能力如何,进而预示其在实际应用场景中的表现。如何在有限的显存下降低排队时间,便是提升并发的一个方向。 + +![](./images/latency.png) + +### 3.2 吞吐量 + +  LLM 的“吞吐量”指标衡量的是在给定时间范围内它可以处理的请求数量或产生输出的数量。通常通过两种方式来衡量:**每秒请求数(QPS)** 和 **每秒输出 tokens 数(TPS)**,你一般可以在模型供应商的开发文档中找到这两个指标。 + +#### 3.2.1 QPS + +  这一指标取决于模型的总生成时间和同时处理的请求数量,即模型处理并发请求的能力如何。然而,总生成时间会根据模型输入和输出的长度而变化。 + +#### 3.2.2 TPS + +  由于 QPS 受总生成时间的影响,而总生成时间又依据模型输出的长度(及较小程度上输入的长度),TPS 成为了衡量吞吐量更常用的指标。 + +  其计算方式为: + +$$TPS = (要生成的\:token\:数量) / 延迟$$ + +## 4 推理框架 + +  为了优化 Transformer 模型的推理性能,出现了各种推理框架。 + +  推理框架的主要目标是优化模型的推理过程,以提高效率和降低资源消耗。以下是一些常见的推理框架/引擎的优化方法,其中部分在前面几章有相关介绍: + +### 4.1 模型压缩 + +- 量化:将模型参数从浮点数转换为低精度表示(如 INT8),以减少计算量和内存占用。 +剪枝:移除模型中不重要的权重或神经元,以减少模型大小和计算复杂度。 +- 蒸馏:使用一个较小的“学生”模型来学习和模仿一个较大的“教师”模型的行为,从而在保持性能的同时减少模型大小。 + +### 4.2 并行化和分布式计算: + +- 数据并行:将数据分成多个小批次,并行处理以提高吞吐量。 +- 模型并行:将模型分成多个部分,分布在不同的计算节点上并行处理。 +- 硬件加速:使用专门的硬件(如 GPU、TPU)并结合高性能算子(如 CUDA、OpneAI Triton)加速模型推理过程。 + +### 4.3 缓存和重用 + +高效缓存、管理中间计算结果甚至是直接存储常见输入结果,以减少重复计算。 + +下面是 BentoML 对几大推理框架的性能测评,以Llama3-8b 部署为例: + +![](./images/bentoml_llama3_8b.png) + + +## 参考文章 + +- [Fast, Secure and Reliable: Enterprise-grade LLM Inference](https://www.databricks.com/blog/fast-secure-and-reliable-enterprise-grade-llm-inference) \ No newline at end of file diff --git a/docs/chapter9/chapter9_1.md b/docs/chapter9/chapter9_1.md index c346fd4..a6a5771 100644 --- a/docs/chapter9/chapter9_1.md +++ b/docs/chapter9/chapter9_1.md @@ -1,153 +1,85 @@ -# 9.1 并发 +# 9.1 异步 -  我们日常使用 ChatGPT 等大语言模型(LLM)应用来提升工作效率,或者通过模型厂商提供的 API 来开发项目。那么,这些服务是如何确保在生产环境中应对每分钟数万次乃至更多请求的同时,还能为全球用户提供始终如一的良好体验呢?这离不开先进的**并发处理技术**的支持。 +在预填充阶段,模型需要接收和处理来自不同用户的多个输入。这时可以利用**异步处理**技术来提高效率。 -## 9.1.1 推理过程 +先热身一下~ -  LLM 推理分为两部分: 预填充阶段(Prefill) 和生成阶段(Generation)。 +## 流式处理(Streaming) -### 9.1.1.1 预填充阶段 +流式处理采用异步通信方式,减少了不必要的等待和阻塞,提高了系统的并发性能和吞吐量 -  在预填充阶段,Transformer 模型会先对输入的句子进行编码。Transformer 使用自注意力机制(self-attention)来处理输入序列,捕捉不同单词之间的关系。模型会将输入句子的每个单词转换成词嵌入(embedding),然后通过多层的注意力机制和前馈神经网络进行处理,生成句子的内部表示。 +想象一下,如果用户每次与 ChatGPT 对话时都必须等待整个回答生成完毕再显示结果,这种体验多少不太舒服。因为人类在交流时往往是实时获取信息的,逐字逐句地接收信息更符合自然的交流习惯,对应着在线部署的 TTFT 要求。 -举个例子: -#### 这里应该画一个示意图 +vLLM 利用异步生成器 (async generator) 来实现流式输出。这允许模型一边生成 token,一边将其 yield 给调用者,而不需要等待整个响应生成完毕,下面是一个简化的实现: - 用户输入:“你好,今天的天气怎么样?” +```python +import asyncio +import random -Transformer 会将这句话中的每个词转换成词嵌入,并使用自注意力机制捕捉句子中每个词之间的关系,生成一个上下文感知的表示。 +async def async_word_generator(sentence): + words = sentence.split() + for word in words: + # 模拟推理时间 + await asyncio.sleep(random.uniform(0.3, 1.0)) + yield word -### 9.1.1.2 生成阶段 +async def stream_sentence(): + sentence = "vLLM 的异步生成器可以实现流式输出 让用户体验更加流畅 同时提高系统效率" + async for word in async_word_generator(sentence): + print(word, end=' ', flush=True) + print() -在生成阶段,Transformer 使用解码器部分,根据预填充阶段生成的上下文表示来生成输出。生成过程通常是逐词进行的,每生成一个词,模型会将其作为输入,再次使用注意力机制更新上下文表示,直到生成完整的回复。 +async def main(): + await stream_sentence() -接着上面的输入示例,模型在生成阶段会逐词输出: +if __name__ == "__main__": + asyncio.run(main()) +``` - “今天的天气很好,阳光明媚,适合出门活动。” - -模型会先生成“今天”,然后将“今天”作为输入继续生成“的”,接着是“天气”,依此类推,直到生成完整的句子。 +此外,vLLM 官方给出的 API 服务器示例使用了 FastAPI 框架,它原生支持异步编程。这使得服务器可以高效地处理多个并发的流式请求。 -## 9.1.2 重要指标 +热身结束!真正的并发性能提升主要来自于服务器架构、算法优化和硬件资源,我们以 vLLM 最近的更新内容为例: -  为了评估 LLM 的并发推理能力,我们最感兴趣的指标是**延迟**(latency)和**吞吐量**(throughput)。 -### 9.1.2.1 延迟 +## 异步通信 -  延迟是评价 LLM 对用户查询给出反馈速度的标尺,塑造了用户对生成型 AI 应用的直观体验。因此在即时交互场景中,低延迟极为关键。为了全面评估模型延迟,可采纳以下几种度量方式: +在早期的 vLLM 架构中,API 服务器(API Server)和推理引擎(vLLM Engine)运行在同一个 Python 进程中。这意味着所有的 HTTP 请求都由同一进程处理,API Server 接收请求后直接调用 vLLM Engine 进行推理。虽然这种方式在负载较低时能够正常工作,但在高并发场景下,会引发性能瓶颈。 -#### 1. **TTFT**(Time to First Token) - -  即从请求发出到接收首个回答 token 的时间间隔。 - -  影响 TFTT 的主要因素有: - -- **网络速度**:取决于系统的带宽以及网络请求在推理时是否拥挤。 -- **输入序列长度**:提示(prompt)越长,则模型在输出第一个令牌之前需要更多处理。 -- **模型大小**:直观上,模型参数量越大,则执行计算以生成响应会增加,并导致 TFTT 变长。 - -  这一指标在“在线流式输出模式”下尤为重要,因为它直接影响用户感知的响应速度。 - -#### 2. **TPOT**(Time per Output Token) - -  即除了首个 token 外,输出每个 token 的平均时长。 - -  较短的 TPOT 可以提高系统的整体响应速度,特别是在需要生成大量文本的情况下,如离线批处理服务。 - -#### 3. 总体延迟 - -  指的是模型的**端到端延迟**:从用户最初输入提示到接收到模型完成的输出之间的时间跨度。 - -  通常我们说的延迟,实际上指的就是这个指标。其计算方式如下: - -$$总体延迟 = TTFT + (TPOT \times 要生成的\:token\:数量)$$ - -  从公式中可以看出,影响 TFTT 的主要因素有: - -- 输出长度:最重要的影响因素,因为它直接决定了 TPOT 部分的大小。输出越长,即需要生成的 token 数量越多,延迟时间也会相应增加。 - -- 预填充时间:对应 TTFT。 - -- 排队时间:由于硬件限制——尤其是 GPU 显存不足时,LLM 可能无法跟上用户的请求速度。这意味着部分输入请求需要先排队等候处理。这也正是 TTFT 成为一项普遍记录指标的原因所在,因为它能揭示模型服务器应对不同数量用户请求的能力如何,进而预示其在实际应用场景中的表现。如何在有限的显存下降低排队时间,便是提升并发的一个方向。 +- CPU 资源竞争:API Server 和 vLLM Engine 都需要大量的 CPU 资源来处理网络请求、数据解析和模型推理。当它们共享同一个进程时,会导致 CPU 资源争用,降低系统的整体性能。 -![](./images/latency.png) - -### 9.1.2.2 吞吐量 - -  LLM 的“吞吐量”指标衡量的是在给定时间范围内它可以处理的请求数量或产生输出的数量。通常通过两种方式来衡量:**每秒请求数(QPS)** 和 **每秒输出 tokens 数(TPS)**,你一般可以在模型供应商的开发文档中找到这两个指标。 - -#### 1. QPS - -  这一指标取决于模型的总生成时间和同时处理的请求数量,即模型处理并发请求的能力如何。然而,总生成时间会根据模型输入和输出的长度而变化。 - -#### 2. TPS - -  由于 QPS 受总生成时间的影响,而总生成时间又依据模型输出的长度(及较小程度上输入的长度),TPS 成为了衡量吞吐量更常用的指标。 - -  其计算方式为: - -$$TPS = (要生成的\:token\:数量) / 延迟$$ - -## 9.1.3 推理框架 - -  为了优化 Transformer 模型的推理性能,出现了各种推理框架。 - -  推理框架的主要目标是优化模型的推理过程,以提高效率和降低资源消耗。以下是一些常见的推理框架及其优化方法,其中部分在前面几章有相关介绍: - -### 9.1.3.1 模型压缩 - -- 量化:将模型参数从浮点数转换为低精度表示(如 INT8),以减少计算量和内存占用。 -剪枝:移除模型中不重要的权重或神经元,以减少模型大小和计算复杂度。 -- 蒸馏:使用一个较小的“学生”模型来学习和模仿一个较大的“教师”模型的行为,从而在保持性能的同时减少模型大小。 +- GIL 限制:由于 Python 的全局解释器锁(GIL),多线程无法真正实现并行执行。这使得在同一进程中处理大量并发请求时,性能受到严重限制。 -### 9.1.3.2 并行化和分布式计算: - -- 数据并行:将数据分成多个小批次,并行处理以提高吞吐量。 -- 模型并行:将模型分成多个部分,分布在不同的计算节点上并行处理。 -- 硬件加速:使用专门的硬件(如 GPU、TPU)并结合高性能算子(如 CUDA、Triton)加速模型推理过程。 - -### 9.1.3.3 缓存和重用 - -缓存中间结果,以减少重复计算。 - - -下面是 BentoML 对几大推理框架的性能测评,以Llama3-8b 部署为例: - -![](./images/bentoml_llama3_8b.png) - - -## 9.1.4 本节大纲(待补充) - -  本节将详细探讨并发处理在大语言模型部署中的关键角色,并将内容划分为以下三大部分: - -## 1. 异步处理 +- 响应延迟增加:当 API Server 被繁重的推理任务阻塞时,新的请求可能无法及时得到处理,导致响应延迟增加,影响用户体验。 -   异步处理是提升并发能力的关键之一。 +### 解决方案:进程分离与异步通信 -   会接触到: +为了解决上述问题,vLLM 在 [PR#6883](https://github.com/vllm-project/vllm/pull/6883) 中引入了新的架构,将 API Server 和 vLLM Engine 分离为两个独立的进程。这两个进程之间通过 ZMQ(ZeroMQ)进行高效的异步通信。 -- 通过使用**异步队列**来处理网络请求,可以有效地提高系统的响应速度和处理能力。 +![](./images/vllm-zmq.png) +> [ZMQ](https://zh.wikipedia.org/wiki/%C3%98MQ) 是一种高性能的异步消息库,能够支持异步消息传递,允许 API Server 非阻塞地将请求发给 vLLM Engine。 -- 在 Nvidia Triton Server 中部署 vLLM(因为不太熟悉 nv 的东西,估计会改动项目) -- LM Deploy +- 进程隔离:API Server 专注于接收和解析 HTTP 请求,以及返回响应;vLLM Engine 专注于请求的调度和模型推理。这样,两个进程各自占用不同的 CPU 资源,互不干扰。 +- 异步通信:利用 ZMQ 的高性能消息传递机制,实现了进程间的异步通信,避免了同步阻塞,提高了系统的并发处理能力。 +- 资源优化:通过进程分离解耦,减少了 Python GIL 对多线程性能的限制,充分发挥多核 CPU 的优势。 -- LightLLM perhaps +采用新的架构后,vLLM 在高并发场景下的性能得到了显著提升: -## 2. 批处理 +![](./images/vllm-benchmark.png) -批处理技术在大语言模型的高效运作中不可或缺。 +### 异步输出处理 -- vLLM 通过动态批处理(dynamic batching)和 pagedattention 来优化性能。 +异步处理的引入虽然会带来一定的延迟,但如果用少量延迟换取 GPU 利用率的提升显然是笔划算买卖。随着批量处理请求增加,系统的并发处理能力也随之增强。 -## 3. 分布式 +![](./images/async-output.png) -- 分布式处理是应对大规模并发请求的另一重要手段。DeepSpeed-fastgen 是一个优秀的分布式解决方案 +在引入异步输出处理之前,GPU 在每次完成前向计算后,必须等待 CPU 完成输出处理才能继续执行下一步。这导致了大量的 GPU 空闲时间,限制了 GPU 计算资源的充分利用。 -- 在 vLLM 中,中央调度器起到了协调各个分布式组件的作用。 +通过引入异步输出处理,输出处理与模型前向计算并行进行。在执行第 $n$ 步计算时,vLLM 并不会等待第 $n$ 步的输出处理完成,而是立即开始第 $n+1$ 步的模型推理。此时,CPU 会并行处理第 $n$ 步的输出。 -- 此外,Ray 也提供了强大的分布式部署能力。结合 DeepSpeed 和 Ray,可以简单、快速、高效地微调和部署大型语言模型。 +由于 GPU 不再需要等待 CPU 处理输出数据,GPU 的计算资源得到了充分利用。这样可以减少 GPU 的空闲时间,提升吞吐量和整体性能,vLLM 的每个输出 token 的处理时间(Time-Per-Output-Token, TPOT)得到了显著优化。例如,在 Llama 70B 模型和 4xH100 GPU 的场景下,TPOT 提升了 8.7%,即 GPU 处理速度提高,使得推理能力大幅度增强。 ## 参考文章 -- [Fast, Secure and Reliable: Enterprise-grade LLM Inference](https://www.databricks.com/blog/fast-secure-and-reliable-enterprise-grade-llm-inference) \ No newline at end of file +- [vLLM v0.6.0: 2.7x Throughput Improvement and 5x Latency Reduction](https://blog.vllm.ai/2024/09/05/perf-update.html) diff --git a/docs/chapter9/chapter9_2.md b/docs/chapter9/chapter9_2.md index 140c411..676b5b9 100644 --- a/docs/chapter9/chapter9_2.md +++ b/docs/chapter9/chapter9_2.md @@ -1,90 +1,248 @@ -# 9.2 异步 +# 9.2 批处理 -在预填充阶段,模型需要接收和处理来自不同用户的多个输入。这时可以利用**异步处理**技术来提高效率。 +同样的例子,面对多个提示词输入时: -先热身一下~ +```python +prompts = [ + "What is the capital of France?", + "如何学习人工智能?", + "今天天气怎么样?" +] +``` + +涉及在实际执行推理操作之前,我们希望将多个查询整合成一个大批次的请求统一处理,这样就提升了系统整体的处理能力(吞吐量)。 + +![](./images/request_batching.png) + +## 静态批处理 (Static Batching) + +一个原始的 batching 方式如下图所示: +![](./images/naive_batching.png) +> 一个 batch 由 S1-4 这四个请求组成,这里上下文长度是 8,那四个请求一共分配 $4 \times 8 = 32$ 块内存, + +可以看到,序列3在第二次迭代后就完成了,但由于静态批处理的限制,GPU 需要等到所有序列都完成后才能继续处理。持续解码会让 latency 低一点,但显然会影响在线部署中的关键指标 TTFT 和吞吐量。 + +相比之下,动态批处理机制(也被称为持续批处理)作为动态批处理的一个特例,展现出更高的灵活性。 + +## 动态批处理(Continuous Batching) + + +![](./images/continuous-batching.png) + +[Orca 论文](https://www.usenix.org/conference/osdi22/presentation/yu) 中采用迭代级调度而不是等待批处理中每个序列完成生成,其中批处理大小由每次迭代确定。这样的好处是,一旦批处理中的一个序列完成生成,就可以插入新序列以取代它,从而比静态分批实现更高的GPU利用率。 + +加州伯克利大学的 vLLM 项目便应用了该批处理框架,并采用 PagedAttention 技术高效管理 kv-cache ,使其推理效率相比 HuggingFace Transformers(基于 Static batching) 的实现提升了24倍。 + +## Chunked Prefill +> sarathi 论文 + +另一个思路就是让 prefill 和 decode 能在一个 batch 中一起做,通过增加计算量大的 prefill 的请求,来达到充分利用 GPU 算力的目的。 + +![](./images/chunked-prefill.png) + +## 传统方式的内存浪费 + +那么传统部署系统中哪些低效问题呢?下面是传统部署系统中面对多个请求时内存分配的示意图: + +![](./images/memory-waste.png) + +其中可以看到三种内存浪费: + +- “内部碎片”(internal fragmentation)发生是因为难以预测生成过程的长度,因此内存被过度预留以应对最大序列长度。 +- “预留”(reserved)表示为未来使用而预留的内存,这些内存在整个请求期间被保留。 +- “外部碎片”(external fragmentation)表示由于批处理中的请求需要不同的预分配大小而导致的低效问题。 + +### 内存碎片化 + +内存碎片化(Memory Fragmentation)是指在内存分配过程中由于内存块的大小和使用方式不均匀,导致的内存浪费问题。 + +在实际应用中,为了应对模型支持的最大输入序列长度(例如 2,048),内存被过度预留。即使实际请求的大小可能远小于 2,048,系统依然会预留 2,048 的内存空间。这种预留的内存空间在整个请求的生命周期内被保留,导致内存浪费。特别是在高并发情况下,多个请求的内存需求可能变化较大,这种浪费和碎片化问题变得更加明显。 + +### 分页内存管理 + +而分页(Paging) 是操作系统的一种内存管理技术,可以有效减少内存碎片。 + +具体来说,分页技术将内存分成固定大小的块,称为“页”(pages)。这些页可以在需要时从磁盘加载到物理内存中,而不必一次性加载整个程序。这就像你需要看某个章节时,再从书架上拿下这本书。这样,操作系统能够更好地管理内存,**减少内存碎片**问题(碎片指的是内存中没有被充分利用的部分)。 + +### PagedAttention -## 9.2.1 流式处理(Streaming) +这样的思想下,我们把前面所说的页称作块(block),把字节看作 token,把进程看作序列。 +![](./images/paging.png) -流式处理采用异步通信方式,减少了不必要的等待和阻塞,提高了系统的并发性能和吞吐量 -想象一下,如果用户每次与 ChatGPT 对话时都必须等待整个回答生成完毕再显示结果,这种体验多少不太舒服。因为人类在交流时往往是实时获取信息的,逐字逐句地接收信息更符合自然的交流习惯。 +| Block | 内容 | 状态 | +|-------|-----------------------|--------------------------------------------| +| Block 1 | Four, Score, and, Seven| 完整使用,无碎片 | +| Block 2 | years, ago, our, <空闲>| 内部碎片化,最后一个槽位未使用 | +| Block 3 | you, only, live, <空闲> | 内部碎片化,最后一个槽位未使用 | +| Block 4 | <空闲>, <空闲>, <空闲>, <空闲>| 完全未使用,没有产生外部碎片 | -流式处理巧妙地解决了这个问题。它让 LLM 能够"边思考边说话,就像人类交谈一样自然。当模型生成第一个词时,它就立即被发送给用户,紧接着是第二个词,第三个词...这样,用户几乎可以实时地看到回答的形成过程,大大提升了交互体验。 +可以看到分页后,外部碎片被消除了,原先 2,038 + 507 的内部碎片只剩 1 + 1,内存浪费只会发生在最后一个块中,十分接近最优利用率(约损耗 4%)。 +。 -流式处理的魅力不仅仅在于提升用户体验。从技术角度来看,它还大大提高了系统的并发性能。传统的方法可能需要等待整个响应生成完毕才能处理下一个请求,而流式处理允许系统同时处理多个请求的不同部分。想象一下,这就像是一个高效的多任务处理器,能够同时应对多个对话,每个对话都在稳步推进。 +![](./images/block-allocation.gif) +> 序列生成示例,每个块内部的数据是连续存储的,而通过块表的索引,不同的块又可以分散地存储在内存中。 -vLLM 利用异步生成器 (async generator) 来实现流式输出。这允许模型一边生成 token,一边将其 yield 给调用者,而不需要等待整个响应生成完毕,下面是一个简化的实现: +总的来说,PagedAttention 的核心思想是将 KV Cache 分成固定大小的块,每个块可以存储固定数量的 token。这种分块策略不仅减少了内存碎片,还提高了内存利用率。 + +## vLLM + +[https://github.com/vllm-project/vllm](https://github.com/vllm-project/vllm) + +vLLM 从传统操作系统的概念如分页和虚拟内存中获得灵感,允许在预填充阶段计算的 KV 缓存在物理上非连续地存储,通过分配固定大小的“页面”或块来实现。然后可以重写注意力机制以操作块对齐的输入,允许在非连续的内存范围内执行注意力操作。 + +![](./images/vllm-hf.png) ```python -import asyncio -import random - -async def async_word_generator(sentence): - words = sentence.split() - for word in words: - # 模拟推理时间 - await asyncio.sleep(random.uniform(0.3, 1.0)) - yield word - -async def stream_sentence(): - sentence = "vLLM 的异步生成器可以实现流式输出 让用户体验更加流畅 同时提高系统效率" - async for word in async_word_generator(sentence): - print(word, end=' ', flush=True) - print() - -async def main(): - await stream_sentence() - -if __name__ == "__main__": - asyncio.run(main()) +from vllm import LLM, SamplingParams + +def create_qwen_prompts(system_prompt: str, user_prompts: list[str]) -> list[str]: + """为Qwen模型从用户提示列表创建格式化的提示""" + prompts = [] + for user_prompt in user_prompts: + prompt = f'''<|im_start|>system +{system_prompt}<|im_end|> +<|im_start|>user +{user_prompt}<|im_end|> +<|im_start|>assistant''' + prompts.append(prompt) + return prompts + +# 提示词示例 +system_prompt = "You are a helpful assistant." +user_prompts = [ + "What is the capital of France?", + "如何学习人工智能?", + "今天天气怎么样?" +] +prompts = create_qwen_prompts(system_prompt, user_prompts) + +# 创建采样参数对象 +sampling_params = SamplingParams( + temperature=0.9, # 控制生成文本的随机性 + top_p=0.95, # 控制采样时考虑的概率质量 + max_tokens=300, # 生成文本的最大长度 +) + +llm = LLM(model="models/Qwen/Qwen2.5-0.5B-Instruct", trust_remote_code=True) + +outputs = llm.generate(prompts, sampling_params) + +for output in outputs: + prompt = output.prompt + generated_text = output.outputs[0].text + print(f"Prompt: {prompt!r}, Generated text: {generated_text!r}") ``` -此外,vLLM 官方给出的 API 服务器示例使用了 FastAPI 框架,它原生支持异步编程。这使得服务器可以高效地处理多个并发的流式请求。 +![](./images/vllm-output.png) + +### 解码算法 + +vLLM 使用三种关键方法实现各种解码算法: + +- fork:从现有序列创建一个新序列。 + +- append:向序列追加一个新令牌。 + +- free:删除序列。 + +#### Parallel Sampling + +在像 ChatGPT 这样的对话助手应用中,有 LLM 为单一输入提示生成多个采样输出,允许用户选择他们偏好的输出的功能。当多个输出共享相同的输入提示时,vLLM 只为提示的 KV 缓存保留一个副本的空间。所有序列的提示的逻辑块被映射到相同的物理块。这允许共享提示的 KV 缓存只存储一次,节省了内存。 + +对于生成的输出,vLLM 在块级别使用写时复制机制。通过 fork 方法从单个输入序列创建多个输出序列,在每次迭代中使用 append 向这些序列添加新令牌,并使用 free 删除满足停止条件的序列。 + +![](./images/parallel-sampling.png) + +当序列需要修改一个共享块时,vLLM 创建一个新的物理块,从原始块复制数据,并更新该序列的映射。这确保了每个序列都拥有修改块的自己的副本,同时仍然共享未改变的块。 -不过虽然对用户来说是流式的,但在底层 vLLM 仍然使用批处理来提高效率。它会预先生成一批 token,然后逐个返回给用户。 +相同的策略应用于束搜索和前缀共享。 -热身结束!真正的并发性能提升主要来自于服务器架构、算法优化和硬件资源,我们以 vLLM 最近的更新内容为例: +#### Beam Search +束搜索是一种解码算法,它在每一步保持一组最可能的前 k 个部分序列(候选者),它允许大型语言模型(LLM)探索多个高概率路径并找到最有可能的输出序列。 -## 9.2.2 异步通信 +通过 PagedAttention,vLLM 不仅能够共享初始提示块的内存,还能在不同候选者之间共享其他块的内存。随着束搜索的进行,候选者共享公共块,并且只在必要时分歧。 -在早期的 vLLM 架构中,API 服务器(API Server)和推理引擎(vLLM Engine)运行在同一个 Python 进程中。这意味着所有的 HTTP 请求都由同一进程处理,API Server 接收请求后直接调用 vLLM Engine 进行推理。虽然这种方式在负载较低时能够正常工作,但在高并发场景下,会引发性能瓶颈。 +vLLM 使用引用计数机制来跟踪每个物理块被多少候选者共享。当一个候选者被丢弃时,其块的引用计数会减少。当引用计数达到零时,相应的物理块被释放,并可以被重新用于其他候选者或序列。 -- CPU 资源竞争:API Server 和 vLLM Engine 都需要大量的 CPU 资源来处理网络请求、数据解析和模型推理。当它们共享同一个进程时,会导致 CPU 资源争用,降低系统的整体性能。 - -- GIL 限制:由于 Python 的全局解释器锁(GIL),多线程无法真正实现并行执行。这使得在同一进程中处理大量并发请求时,性能受到严重限制。 - -- 响应延迟增加:当 API Server 被繁重的推理任务阻塞时,新的请求可能无法及时得到处理,导致响应延迟增加,影响用户体验。 +![](./images/beam-search.png) -### 9.2.2.1 解决方案:进程分离与异步通信 +在束搜索的例子中,vLLM 使用引用计数机制有效地管理不同束候选者之间物理块的共享。 +> 引用计数是一种内存管理技术,用于跟踪对特定资源的引用次数,在这里是物理块。当资源不再需要时,可以安全地释放它。类似 C++ 中的 `std::shared_ptr` 智能指针。 -为了解决上述问题,vLLM 在 [PR#6883](https://github.com/vllm-project/vllm/pull/6883) 中引入了新的架构,将 API Server 和 vLLM Engine 分离为两个独立的进程。这两个进程之间通过 ZMQ(ZeroMQ)进行高效的异步通信。 +每个物理块都有一个相关的引用计数,代表当前引用它的逻辑块(即束候选者)的数量。当创建一个新的束候选者并与现有候选者共享一个物理块时,该物理块的引用计数增加。随着束搜索的进行,当候选者被丢弃(例如,在例子中的候选者 0 和 3),与这些候选者相关的物理块的引用计数被减少。当物理块的引用计数达到零时,意味着当前没有束候选者正在使用该块,它可以被安全地释放(例如,在例子中的块 2、4、5 和 8)。当新的候选者需要修改一个共享的物理块(例如,在生成新令牌时),vLLM 应用写时复制机制。它创建一个新的物理块,复制原始块的数据,并相应地更新引用计数。 -![](./images/vllm-zmq.png) -> [ZMQ](https://zh.wikipedia.org/wiki/%C3%98MQ) 是一种高性能的异步消息库,能够支持异步消息传递,允许 API Server 非阻塞地将请求发给 vLLM Engine。 +引用计数机制允许 vLLM 高效地管理束候选者使用的内存,因为它使系统能够: +- 在可能的情况下在候选者之间共享物理块,减少内存使用。 -- 进程隔离:API Server 专注于接收和解析 HTTP 请求,以及返回响应;vLLM Engine 专注于请求的调度和模型推理。这样,两个进程各自占用不同的 CPU 资源,互不干扰。 -- 异步通信:利用 ZMQ 的高性能消息传递机制,实现了进程间的异步通信,避免了同步阻塞,提高了系统的并发处理能力。 -- 资源优化:通过进程分离解耦,减少了 Python GIL 对多线程性能的限制,充分发挥多核 CPU 的优势。 +- 跟踪物理块何时不再需要并可以被释放,防止内存泄漏。 -采用新的架构后,vLLM 在高并发场景下的性能得到了显著提升: +- 实现写时复制机制,允许候选者修改共享块而不影响其他候选者,同时最小化所需的内存复制量。 -![](./images/vllm-benchmark.png) +#### Prefix Caching -### 9.2.2.2 异步输出处理 +前缀缓存是一种实验性的优化技术,通过缓存前缀的计算结果(预计算)来减少重复计算,从而加速生成过程。这种方法特别适用于需要生成长文本的场景 -异步处理的引入虽然会带来一定的延迟,但如果用少量延迟换取 GPU 利用率的提升显然是笔划算买卖。随着批量处理请求增加,系统的并发处理能力也随之增强。 +在某些场景(如机器翻译)中,多个输入提示可能共享一个常见的前缀,例如任务描述或示例: -![](./images/async-output.png) +> 你是一个精通中英翻译的专家,请你将下面的内容翻译为中文,风格为{目标风格}... -在引入异步输出处理之前,GPU 在每次完成前向计算后,必须等待 CPU 完成输出处理才能继续执行下一步。这导致了大量的 GPU 空闲时间,限制了 GPU 计算资源的充分利用。 +vLLM 允许 LLM API 服务提供商提前存储共享前缀的 KV 缓存,减少重复计算。 -通过引入异步输出处理,输出处理与模型前向计算并行进行。在执行第 $n$ 步计算时,vLLM 并不会等待第 $n$ 步的输出处理完成,而是立即开始第 $n+1$ 步的模型推理。此时,CPU 会并行处理第 $n$ 步的输出。 +![](./images/shared_prefix.png) + +#### 混合解码 + +vLLM 的 PagedAttention 允许同时处理具有不同解码偏好的请求,包括但不限于上述介绍的几种算法。 + +这是通过一个共同的映射层实现的,该层将逻辑块转换为物理块。LLM 及其执行内核使用调度器提供的物理块 ID 工作,无需处理序列间复杂的内存共享模式。这种抽象使 vLLM 能够高效地批量处理具有不同解码需求的请求,提高了整体系统吞吐量。 + +#### 投机解码(Speculative Decoding) + +使用投机解码可以加快生成文本的过程,而不会改变最终结果。推测解码涉及并行运行两个模型,这已被证明有望将语言模型推理的速度提高 2-3 倍。 + +自回归采样解码 k 个标记需要对模型进行 k 次串行运行,因此速度较慢. + +投机解码通过并行运行两个模型:目标模型(真正用于生产的大模型)和近似模型(一个小很多的模型,甚至可以是 n-gram 模型),以加速主 LLM 的推理过程。 + +在预测token 预测难度不同 +![](./images/speculate.png) + +预测标记 'of ' 真的很容易,并且可以通过小得多的模型轻松预测,而标记 'Edinburg' 的预测相对来说很困难,而较小的模型可能无法预测 + +尽管 Transformer 一次生成一个 token,但可以一次处理多个 token。在生成下一个 token 时,模型可以一次检查序列中的所有 token, 通过计算序列中每个 token 的概率来实现此目的。在上图的例子中,假设较小的模型预测结果为 “Toronto”,但正确的单词是“Edinburgh”,较大的模型可以看到“Toronto”的概率很低,拒绝该采样后将其更正为“Edinburgh”。 + +除了 draft model,还可以使用更简单的 n-gram 来做投机解码: +```python +from vllm import LLM, SamplingParams + +prompts = [ + "The future of AI is", +] +sampling_params = SamplingParams(temperature=0.8, top_p=0.95) + +llm = LLM( + model="facebook/opt-6.7b", + tensor_parallel_size=1, + # speculative_model="facebook/opt-125m", 使用 draft model + speculative_model="[ngram]", # 使用 n-gram 模型 + num_speculative_tokens=5, # 预测 5 个 token + ngram_prompt_lookup_max=4, # 使用前 4 个 token 来预测下一个 token +) +outputs = llm.generate(prompts, sampling_params) + +for output in outputs: + prompt = output.prompt + generated_text = output.outputs[0].text + print(f"Prompt: {prompt!r}, Generated text: {generated_text!r}") +``` -由于 GPU 不再需要等待 CPU 处理输出数据,GPU 的计算资源得到了充分利用。这样可以减少 GPU 的空闲时间,提升吞吐量和整体性能,vLLM 的每个输出 token 的处理时间(Time-Per-Output-Token, TPOT)得到了显著优化。例如,在 Llama 70B 模型和 4xH100 GPU 的场景下,TPOT 提升了 8.7%,即 GPU 处理速度提高,使得推理能力大幅度增强。 ## 参考文章 -- [vLLM v0.6.0: 2.7x Throughput Improvement and 5x Latency Reduction](https://blog.vllm.ai/2024/09/05/perf-update.html) +- [Mastering LLM Techniques: Inference Optimization](https://developer.nvidia.com/blog/mastering-llm-techniques-inference-optimization/) +- [vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention](https://blog.vllm.ai/2023/06/20/vllm.html) +- [Fast, Secure and Reliable: Enterprise-grade LLM Inference](https://www.databricks.com/blog/fast-secure-and-reliable-enterprise-grade-llm-inference) +- [猛猿:图解大模型计算加速系列:分离式推理架构2,模糊分离与合并边界的chunked-prefills](https://mp.weixin.qq.com/s/_nm2Fwz2FlkcLuXnWDB9PA) diff --git a/docs/chapter9/chapter9_3.md b/docs/chapter9/chapter9_3.md index 9464ab7..e635250 100644 --- a/docs/chapter9/chapter9_3.md +++ b/docs/chapter9/chapter9_3.md @@ -1,181 +1,135 @@ -# 9.3 批处理 +# 9.3 分布式 -同样的例子, +如今, 像 Llama-3.1-405b 这样的大语言模型显然装在一块 GPU 内,而是通过张量并行实现了分布式推理,大幅降低了训练/推理的延迟。 -举例: -多个用户同时输入: +我们在[第八章](https://datawhalechina.github.io/llm-deploy/#/chapter8/chapter8_4)介绍过,张量并行是一种常见的模型并行技术,它将模型的权重张量切分到多个设备上,使得每个设备只负责计算一部分张量的操作。这样做可以使模型在多个 GPU 上同时计算,加快处理速度。 -用户A:“你好,今天的天气怎么样?” -用户B:“请问现在几点了?” -用户C:“能推荐一本好书吗?” +## Ray-Serve -涉及在实际执行推理操作之前,将多个查询整合成一个大批次的请求统一处理,这样就提升了系统整体的处理能力(吞吐量)。 +在传统的生产环境中,部署机器学习模型往往需要构建复杂的基础设施。这通常涉及使用消息队列(如 Redis 或 RabbitMQ)来处理数据流,结合 Celery 等工作线程框架来调度任务。这些系统被精心拼接在一起,以应对负载均衡、工作进程管理以及应用程序各部分之间的通信等关键任务。尽管这种方法在功能上是可行的,但它往往需要大量的工程投入,以确保系统具备容错能力、能够高效扩展并保持低延迟的响应时间。 -![](./images/request_batching.png) +在这种背景下,Ray Serve 提供了一种更为简洁的解决方案,极大地简化了机器学习模型的部署过程。Ray Serve 的核心优势在于,它将传统上需要独立管理的消息队列和工作进程的功能,巧妙地整合到了一个统一的框架中。这意味着开发人员不再需要处理复杂的基础设施设置,可以将更多精力集中在模型的核心功能上。Ray Serve 通过直观的 API,隐藏了底层的复杂性,同时提供了强大的负载分配、容错和动态扩展能力。结果是,模型的部署不仅变得更快、更简单,还显著提升了系统的可靠性和响应速度。 -## 9.3.1 静态批处理 (Static Batching) +### Ray Serve 的核心优势 -![](./images/naive_batching.png) -> 经典图:一个 batch 由 S1-4 这四个请求组成,这里上下文长度是 8,那四个请求一共分配 $4 \times 8 = 32$ 块内存, +Ray Serve 专为处理并发请求而设计,这使它在生产环境中的表现尤为出色。 -可以看到,序列3在第二次迭代后就完成了,但由于静态批处理的限制,GPU 需要等到所有序列都完成后才能继续处理。 +- 并发处理能力: 随着请求量的增加,Ray Serve 能够智能地在可用资源之间分配这些请求,确保每个请求都能得到及时处理。这种并发管理能力使得系统在面对高负载时依然能够保持稳定的性能。 -相比之下,动态批处理机制作为动态批处理的一个特例,展现出更高的灵活性。 +- 高效的请求批处理: Ray Serve 支持请求批处理,这意味着它可以将多个输入合并处理,特别适合那些在处理批量数据时比逐一处理更为高效的模型。通过批处理,系统的整体吞吐量得以显著提升。 -## 9.3.2 动态批处理(Continuous Batching) -也被称为持续批处理 +- 精细的资源管理: Ray Serve 允许对每个模型实例分配的资源(如 CPU、GPU、内存)进行精细控制。这种精细化的资源管理确保了资源的最优利用,避免了资源的浪费,同时防止了性能瓶颈的出现。 -![](./images/continuous-batching.png) +- 异步编程的支持: Ray Serve 支持异步编程,这意味着即使在处理长时间运行的推理任务时,系统的其他部分也不会被阻塞。这种设计提高了系统的可扩展性,使得它能够更高效地处理大量并发请求。 -Orca 论文中采用迭代级调度而不是等待批处理中每个序列完成生成,其中批处理大小由每次迭代确定。这样的好处是,一旦批处理中的一个序列完成生成,就可以插入新序列以取代它,从而比静态分批实现更高的GPU利用率。 - -加州伯克利大学的 vLLM 项目便应用了该批处理框架,并采用 PagedAttention 技术管理 kv-cache ,使其推理效率相比 HuggingFace Transformers(基于 Static batching) 的实现提升了24倍。 - -## 9.3.3 vLLM - -[https://github.com/vllm-project/vllm](https://github.com/vllm-project/vllm) - -vLLM 从传统操作系统的概念如分页和虚拟内存中获得灵感,允许在预填充阶段计算的 KV 缓存在物理上非连续地存储,通过分配固定大小的“页面”或块来实现。然后可以重写注意力机制以操作块对齐的输入,允许在非连续的内存范围内执行注意力操作。 - -![](./images/vllm-hf.png) - -### 9.3.3.1 重温 kv-cache - -在训练过程中,Attention 机制会计算查询(Q)、键(K)和值(V)矩阵的所有元素之间的关系。这意味着模型会使用**完整的 QKV 矩阵** 来计算注意力分数和加权和,从而生成所有可能的 next token。 - -![](./images/kv-cache.png) - -而在推理过程中我们只关心预测 next token,为了提高效率,只需要计算当前最尾的一个查询向量(Q[-1])与所有的键向量(K[:])和值向量(V[:])之间的关系。通过计算好的 k 和 v 值,我们可以用空间换时间: - -无 kv-cache 时, - -```python -idx = cat(idx, next_idx) -``` - -开启 kv-cache 后, +使用 vLLM 做张量并行,Ray 做数据并行: ```python -idx = next_idx +import os +import ray +from typing import List +from vllm import LLM, SamplingParams + +os.environ["CUDA_VISIBLE_DEVICES"] = "0,1,2,3,4,5,6,7" + +@ray.remote(num_gpus=0) +def infer(model_path: str, prompts: List[str]): + """ + 使用vLLM进行推理并返回结果。 + + Args: + model_path (str): 模型路径 + prompts (List[str]): 输入提示列表 + + Returns: + List: 推理结果列表 + """ + try: + llm = LLM( + model=model_path, + tensor_parallel_size=4, + dtype="float16", + seed=42, + disable_log_stats=True, + trust_remote_code=True, + gpu_memory_utilization=0.95, + enforce_eager=True, + ) + + sampling_params = SamplingParams( + top_p=1.0, + top_k=-1, + n=3, + temperature=0.5, + max_tokens=2048 + ) + + outputs = llm.generate(prompts=prompts, sampling_params=sampling_params) + + return [output.outputs[0].text for output in outputs] + + except Exception as e: + print(f"推理过程中发生错误: {str(e)}") + return [] + +def main(model_path: str, prompts: List[str], num_processes: int = 2): + """ + 主函数,用于初始化Ray并分配任务。 + + Args: + model_path (str): 模型路径 + prompts (List[str]): 所有输入提示 + num_processes (int): 并行进程数 + """ + ray.init() + + try: + per_process_nums = len(prompts) // num_processes + output_futures = [] + + for idx in range(num_processes): + start = idx * per_process_nums + end = start + per_process_nums if idx < num_processes - 1 else len(prompts) + process_prompts = prompts[start:end] + + output_futures.append(infer.remote(model_path, process_prompts)) + + # 获取所有任务的结果 + all_outputs = ray.get(output_futures) + + for i, outputs in enumerate(all_outputs): + print(f"进程 {i} 的结果:") + for j, output in enumerate(outputs): + print(f" 提示 {j}: {output}") + + print("所有推理任务已完成") + + except Exception as e: + print(f"运行过程中发生错误: {str(e)}") + finally: + ray.shutdown() + +if __name__ == "__main__": + MODEL_PATH = "path/to/your/model" + PROMPTS = ["你好,请介绍一下自己", "什么是大语言模型?", "请写一首诗"] + NUM_PROCESSES = 2 # 并行进程数 + + main(MODEL_PATH, PROMPTS, NUM_PROCESSES) ``` +## 拓展:DistriFusion -![](./images/kv.png) - -### 9.3.3.2 内存碎片化 - -内存碎片化(Memory Fragmentation)是指在内存分配过程中由于内存块的大小和使用方式不均匀,导致的内存浪费问题。 - -在实际应用中,为了应对模型支持的最大输入序列长度(例如 2,048),内存被过度预留。即使实际请求的大小可能远小于 2,048,系统依然会预留 2,048 的内存空间。这种预留的内存空间在整个请求的生命周期内被保留,导致内存浪费。特别是在高并发情况下,多个请求的内存需求可能变化较大,这种浪费和碎片化问题变得更加明显。 - -### 9.3.3.3 分页内存管理 - -而分页(Paging) 是操作系统的一种内存管理技术,可以有效减少内存碎片。 - -具体来说,分页技术将内存分成固定大小的块,称为“页”(pages)。这些页可以在需要时从磁盘加载到物理内存中,而不必一次性加载整个程序。这就像你需要看某个章节时,再从书架上拿下这本书。这样,操作系统能够更好地管理内存,**减少内存碎片**问题(碎片指的是内存中没有被充分利用的部分)。 - -### 9.3.3.4 PagedAttention - -这样的思想下,我们把前面所说的页称作块(block),把字节看作 token,把进程看作序列。 - -![](./images/paging.png) -> “预留”(reserved)表示为未来使用而预留的内存,这些内存在整个请求期间被保留。 -“内部碎片”(internal fragmentation)发生是因为难以预测生成过程的长度,因此内存被过度预留以应对最大序列长度。 -“外部碎片”(external fragmentation)表示由于批处理中的请求需要不同的预分配大小而导致的低效问题。 - -| Block | 内容 | 状态 | -|-------|-----------------------|--------------------------------------------| -| Block 1 | Four, Score, and, Seven| 完整使用,无碎片 | -| Block 2 | years, ago, our, <空闲>| 内部碎片化,最后一个槽位未使用 | -| Block 3 | you, only, live, <空闲> | 内部碎片化,最后一个槽位未使用 | -| Block 4 | <空闲>, <空闲>, <空闲>, <空闲>| 完全未使用,没有产生外部碎片 | - -可以看到分页后,外部碎片被消除了,原先 2,038 + 507 的内部碎片只剩 1 + 1,内存浪费只会发生在最后一个块中,十分接近最优利用率(约损耗 4%)。 -。 - -![](./images/block-allocation.gif) -> 序列生成示例,每个块内部的数据是连续存储的,而通过块表的索引,不同的块又可以分散地存储在内存中。 - -### 9.3.3.5 解码算法 - -vLLM 使用三种关键方法实现各种解码算法: - -- fork:从现有序列创建一个新序列。 - -- append:向序列追加一个新令牌。 - -- free:删除序列。 - -#### 9.3.3.6 Parallel Sampling - -在像 ChatGPT 这样的对话助手应用中,有 LLM 为单一输入提示生成多个采样输出,允许用户选择他们偏好的输出的功能。当多个输出共享相同的输入提示时,vLLM 只为提示的 KV 缓存保留一个副本的空间。所有序列的提示的逻辑块被映射到相同的物理块。这允许共享提示的 KV 缓存只存储一次,节省了内存。 - -对于生成的输出,vLLM 在块级别使用写时复制机制。通过 fork 方法从单个输入序列创建多个输出序列,在每次迭代中使用 append 向这些序列添加新令牌,并使用 free 删除满足停止条件的序列。 - -![](./images/parallel-sampling.png) - -当序列需要修改一个共享块时,vLLM 创建一个新的物理块,从原始块复制数据,并更新该序列的映射。这确保了每个序列都拥有修改块的自己的副本,同时仍然共享未改变的块。 - -相同的策略应用于束搜索和前缀共享。 - -#### 9.3.3.7 Beam Search - -束搜索是一种解码算法,它在每一步保持一组最可能的前 k 个部分序列(候选者),它允许大型语言模型(LLM)探索多个高概率路径并找到最有可能的输出序列。 - -通过 PagedAttention,vLLM 不仅能够共享初始提示块的内存,还能在不同候选者之间共享其他块的内存。随着束搜索的进行,候选者共享公共块,并且只在必要时分歧。 - -vLLM 使用引用计数机制来跟踪每个物理块被多少候选者共享。当一个候选者被丢弃时,其块的引用计数会减少。当引用计数达到零时,相应的物理块被释放,并可以被重新用于其他候选者或序列。 - -![](./images/beam-search.png) - -在束搜索的例子中,vLLM 使用引用计数机制有效地管理不同束候选者之间物理块的共享。 -> 引用计数是一种内存管理技术,用于跟踪对特定资源的引用次数,在这里是物理块。当资源不再需要时,可以安全地释放它。类似 C++ 中的 `std::shared_ptr` 智能指针。 - -每个物理块都有一个相关的引用计数,代表当前引用它的逻辑块(即束候选者)的数量。当创建一个新的束候选者并与现有候选者共享一个物理块时,该物理块的引用计数增加。随着束搜索的进行,当候选者被丢弃(例如,在例子中的候选者 0 和 3),与这些候选者相关的物理块的引用计数被减少。当物理块的引用计数达到零时,意味着当前没有束候选者正在使用该块,它可以被安全地释放(例如,在例子中的块 2、4、5 和 8)。当新的候选者需要修改一个共享的物理块(例如,在生成新令牌时),vLLM 应用写时复制机制。它创建一个新的物理块,复制原始块的数据,并相应地更新引用计数。 - -引用计数机制允许 vLLM 高效地管理束候选者使用的内存,因为它使系统能够: - -- 在可能的情况下在候选者之间共享物理块,减少内存使用。 - -- 跟踪物理块何时不再需要并可以被释放,防止内存泄漏。 - -- 实现写时复制机制,允许候选者修改共享块而不影响其他候选者,同时最小化所需的内存复制量。 - -#### 9.3.3.8 Prefix Caching - -前缀缓存是一种实验性的优化技术,通过缓存前缀的计算结果(预计算)来减少重复计算,从而加速生成过程。这种方法特别适用于需要生成长文本的场景 - -在某些场景(如机器翻译)中,多个输入提示可能共享一个常见的前缀,例如任务描述或示例: - -> 你是一个精通中英翻译的专家,请你将下面的内容翻译为中文,风格为{目标风格}... - -vLLM 允许 LLM API 服务提供商提前存储共享前缀的 KV 缓存,减少重复计算。 - -![](./images/shared_prefix.png) - -#### 9.3.3.9 混合解码 - -vLLM 的 PagedAttention 允许同时处理具有不同解码偏好的请求,包括但不限于上述介绍的几种算法。 - -这是通过一个共同的映射层实现的,该层将逻辑块转换为物理块。LLM 及其执行内核使用调度器提供的物理块 ID 工作,无需处理序列间复杂的内存共享模式。这种抽象使 vLLM 能够高效地批量处理具有不同解码需求的请求,提高了整体系统吞吐量。 - -#### 9.3.3.10 投机解码(Speculative Decoding) - -使用投机解码可以加快生成文本的过程,而不会改变最终结果。推测解码涉及并行运行两个模型,这已被证明有望将语言模型推理的速度提高 2-3 倍。 - -自回归采样解码 k 个标记需要对模型进行 k 次串行运行,因此速度较慢. - -投机解码通过并行运行两个模型:目标模型(真正用于生产的大模型)和近似模型(一个小很多的模型,甚至可以是 n-gram 模型),以加速主 LLM 的推理过程。 - -在预测token 预测难度不同 -![](./images/speculate.png) +对于 Diffusion 模型来说,张量并行在处理其大规模激活(activation)时会带来较大的通信开销。这是因为在多 GPU 间进行并行计算时,必须频繁地在 GPU 之间传输数据,这种通信开销会抵消了并行带来的速度提升。 -预测标记 'of ' 真的很容易,并且可以通过小得多的模型轻松预测,而标记 'Edinburg' 的预测相对来说很困难,而较小的模型可能无法预测 +![](./images/distrufuser.png) -尽管 Transformer 一次生成一个 token,但可以一次处理多个 token。在生成下一个 token 时,模型可以一次检查序列中的所有 token, 通过计算序列中每个 token 的概率来实现此目的。在上图的例子中,假设较小的模型预测结果为 “Toronto”,但正确的单词是“Edinburgh”,较大的模型可以看到“Toronto”的概率很低,拒绝该采样后将其更正为“Edinburgh”。 +图中用红色箭头表示不同设备之间的异步通信。由于Patch 1和Patch 2是并行处理的,因此它们在U-Net处理过程中需要进行通信。这种异步通信有助于在不同设备之间交换激活信息,以确保生成的图像一致性和质量。 +通过异步通信,不同设备可以在不完全同步的情况下进行数据交换,从而更高效地利用计算资源。 +![](./images/distrifusion.png) -## 参考文章 +Displaced Patch Parallelism 算法通过以下方式优化了图像生成模型的并行计算: -- [Mastering LLM Techniques: Inference Optimization](https://developer.nvidia.com/blog/mastering-llm-techniques-inference-optimization/) -- [vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention](https://blog.vllm.ai/2023/06/20/vllm.html) -- [Fast, Secure and Reliable: Enterprise-grade LLM Inference](https://www.databricks.com/blog/fast-secure-and-reliable-enterprise-grade-llm-inference) +分割图像:将输入图像分割为多个patch,分别在不同的设备上进行并行处理。 +异步通信:利用设备之间的异步通信来传输数据,确保并行计算的同步性和效率。 +激活复用:在计算过程中复用之前的激活值,减少重复计算,提高效率。 +通信与计算的重叠:通过异步通信和计算的重叠,实现更高效的资源利用,减少计算等待时间。 diff --git a/docs/chapter9/chapter9_4.md b/docs/chapter9/chapter9_4.md deleted file mode 100644 index 60c85eb..0000000 --- a/docs/chapter9/chapter9_4.md +++ /dev/null @@ -1,135 +0,0 @@ -# 9.4 分布式 - -如今, 像 Llama-3.1-405b 这样的大语言模型显然装在一块 GPU 内,而是通过张量并行实现了分布式推理,大幅降低了训练/推理的延迟。 - -我们在[第八章](https://datawhalechina.github.io/llm-deploy/#/chapter8/chapter8_4)介绍过,张量并行是一种常见的模型并行技术,它将模型的权重张量切分到多个设备上,使得每个设备只负责计算一部分张量的操作。这样做可以使模型在多个 GPU 上同时计算,加快处理速度。 - -## 9.4.1 Ray-Serve - -在传统的生产环境中,部署机器学习模型往往需要构建复杂的基础设施。这通常涉及使用消息队列(如 Redis 或 RabbitMQ)来处理数据流,结合 Celery 等工作线程框架来调度任务。这些系统被精心拼接在一起,以应对负载均衡、工作进程管理以及应用程序各部分之间的通信等关键任务。尽管这种方法在功能上是可行的,但它往往需要大量的工程投入,以确保系统具备容错能力、能够高效扩展并保持低延迟的响应时间。 - -在这种背景下,Ray Serve 提供了一种更为简洁的解决方案,极大地简化了机器学习模型的部署过程。Ray Serve 的核心优势在于,它将传统上需要独立管理的消息队列和工作进程的功能,巧妙地整合到了一个统一的框架中。这意味着开发人员不再需要处理复杂的基础设施设置,可以将更多精力集中在模型的核心功能上。Ray Serve 通过直观的 API,隐藏了底层的复杂性,同时提供了强大的负载分配、容错和动态扩展能力。结果是,模型的部署不仅变得更快、更简单,还显著提升了系统的可靠性和响应速度。 - -### 9.4.1.1 Ray Serve 的核心优势 - -Ray Serve 专为处理并发请求而设计,这使它在生产环境中的表现尤为出色。 - -- 并发处理能力: 随着请求量的增加,Ray Serve 能够智能地在可用资源之间分配这些请求,确保每个请求都能得到及时处理。这种并发管理能力使得系统在面对高负载时依然能够保持稳定的性能。 - -- 高效的请求批处理: Ray Serve 支持请求批处理,这意味着它可以将多个输入合并处理,特别适合那些在处理批量数据时比逐一处理更为高效的模型。通过批处理,系统的整体吞吐量得以显著提升。 - -- 精细的资源管理: Ray Serve 允许对每个模型实例分配的资源(如 CPU、GPU、内存)进行精细控制。这种精细化的资源管理确保了资源的最优利用,避免了资源的浪费,同时防止了性能瓶颈的出现。 - -- 异步编程的支持: Ray Serve 支持异步编程,这意味着即使在处理长时间运行的推理任务时,系统的其他部分也不会被阻塞。这种设计提高了系统的可扩展性,使得它能够更高效地处理大量并发请求。 - -使用 vLLM 做张量并行,Ray 做数据并行: - -```python -import os -import ray -from typing import List -from vllm import LLM, SamplingParams - -os.environ["CUDA_VISIBLE_DEVICES"] = "0,1,2,3,4,5,6,7" - -@ray.remote(num_gpus=0) -def infer(model_path: str, prompts: List[str]): - """ - 使用vLLM进行推理并返回结果。 - - Args: - model_path (str): 模型路径 - prompts (List[str]): 输入提示列表 - - Returns: - List: 推理结果列表 - """ - try: - llm = LLM( - model=model_path, - tensor_parallel_size=4, - dtype="float16", - seed=42, - disable_log_stats=True, - trust_remote_code=True, - gpu_memory_utilization=0.95, - enforce_eager=True, - ) - - sampling_params = SamplingParams( - top_p=1.0, - top_k=-1, - n=3, - temperature=0.5, - max_tokens=2048 - ) - - outputs = llm.generate(prompts=prompts, sampling_params=sampling_params) - - return [output.outputs[0].text for output in outputs] - - except Exception as e: - print(f"推理过程中发生错误: {str(e)}") - return [] - -def main(model_path: str, prompts: List[str], num_processes: int = 2): - """ - 主函数,用于初始化Ray并分配任务。 - - Args: - model_path (str): 模型路径 - prompts (List[str]): 所有输入提示 - num_processes (int): 并行进程数 - """ - ray.init() - - try: - per_process_nums = len(prompts) // num_processes - output_futures = [] - - for idx in range(num_processes): - start = idx * per_process_nums - end = start + per_process_nums if idx < num_processes - 1 else len(prompts) - process_prompts = prompts[start:end] - - output_futures.append(infer.remote(model_path, process_prompts)) - - # 获取所有任务的结果 - all_outputs = ray.get(output_futures) - - for i, outputs in enumerate(all_outputs): - print(f"进程 {i} 的结果:") - for j, output in enumerate(outputs): - print(f" 提示 {j}: {output}") - - print("所有推理任务已完成") - - except Exception as e: - print(f"运行过程中发生错误: {str(e)}") - finally: - ray.shutdown() - -if __name__ == "__main__": - MODEL_PATH = "path/to/your/model" - PROMPTS = ["你好,请介绍一下自己", "什么是大语言模型?", "请写一首诗"] - NUM_PROCESSES = 2 # 并行进程数 - - main(MODEL_PATH, PROMPTS, NUM_PROCESSES) -``` -## 9.4.2 拓展:DistriFusion - -对于 Diffusion 模型来说,张量并行在处理其大规模激活(activation)时会带来较大的通信开销。这是因为在多 GPU 间进行并行计算时,必须频繁地在 GPU 之间传输数据,这种通信开销会抵消了并行带来的速度提升。 - -![](./images/distrufuser.png) - -图中用红色箭头表示不同设备之间的异步通信。由于Patch 1和Patch 2是并行处理的,因此它们在U-Net处理过程中需要进行通信。这种异步通信有助于在不同设备之间交换激活信息,以确保生成的图像一致性和质量。 -通过异步通信,不同设备可以在不完全同步的情况下进行数据交换,从而更高效地利用计算资源。 - -![](./images/distrifusion.png) - -Displaced Patch Parallelism 算法通过以下方式优化了图像生成模型的并行计算: - -- 分割图像:将输入图像分割为多个patch,分别在不同的设备上进行并行处理。 -- 异步通信:利用设备之间的异步通信来传输数据,确保并行计算的同步性和效率。 -- 激活复用:在计算过程中复用之前的激活值,减少重复计算,提高效率。 -- 通信与计算的重叠:通过异步通信和计算的重叠,实现更高效的资源利用,减少计算等待时间。 diff --git a/docs/chapter9/images/chunked-prefill.png b/docs/chapter9/images/chunked-prefill.png new file mode 100644 index 0000000..b4c1f6d Binary files /dev/null and b/docs/chapter9/images/chunked-prefill.png differ diff --git a/docs/chapter9/images/llm-inference.png b/docs/chapter9/images/llm-inference.png new file mode 100644 index 0000000..55b2cca Binary files /dev/null and b/docs/chapter9/images/llm-inference.png differ diff --git a/docs/chapter9/images/memory-waste.png b/docs/chapter9/images/memory-waste.png new file mode 100644 index 0000000..b647601 Binary files /dev/null and b/docs/chapter9/images/memory-waste.png differ diff --git a/docs/chapter9/images/paging.png b/docs/chapter9/images/paging.png index c26e6ae..097b750 100644 Binary files a/docs/chapter9/images/paging.png and b/docs/chapter9/images/paging.png differ diff --git a/docs/chapter9/images/vllm-output.png b/docs/chapter9/images/vllm-output.png new file mode 100644 index 0000000..78326d3 Binary files /dev/null and b/docs/chapter9/images/vllm-output.png differ