LLM服务指的是部署和运行大型语言模型(LLM)以处理用户请求的过程。这涉及获取通常经过离线训练的LLM,并将其设置为能够实时响应查询。
以下是LLM服务的具体内容细分
高效处理:由于LLM的计算成本高昂,因此会采用诸如将多个用户请求一起批处理的服务技术,以此优化资源利用率并加快响应时间。模型部署:LLM模型被部署在能够处理相应处理需求的服务器或云平台上。API创建:创建应用程序编程接口(API),以便用户能够与LLM进行交互并发送查询。基础设施管理:服务系统需要具备可扩展性和可靠性,以应对大量用户的访问,并确保系统持续稳定运行 。
有多种框架可用于LLM服务,每种框架都有其独特优势。接下来让我们详细探讨一下。
- 在本地运行LLM
PrivateGPT、llama.cpp、Ollama、GPT4All、llamafile等项目的流行,凸显了在本地(即在自己的设备上)运行LLM的需求。
这至少带来两个重要好处:
隐私性:我们的数据不会被发送给第三方,也不受商业服务条款的约束。
成本:不存在推理费用,这对于像长时间运行的模拟、摘要等令牌密集型应用程序而言至关重要。
在本地运行LLM需要满足以下几个条件:
开源LLM:需要一个可以自由修改和共享的开源LLM。
推理能力:能够在我们的设备上运行此LLM,并且延迟在可接受范围内。
1.1 开源LLM
如今,用户能够访问数量迅速增长的开源LLM。
这些LLM至少可以从两个维度进行评估(见图):
基础模型:基础模型是什么,它是如何训练的?
微调方法:基础模型是否经过微调?如果是,使用了哪组指令?
也可以使用多个排行榜来评估这些模型的相对性能,包括:
LmSys LM系统
GPT4All
HuggingFace
为此,已经出现了一些框架,用于支持在各种设备上对开源LLM进行推理:
llama.cpp:使用C++ 并通过权重优化/量化实现LLAMA推理代码。
gpt4all:优化的C后端,用于推理。
Ollama:将模型权重和环境捆绑到一个应用程序中,该应用程序可在设备上运行并提供LLM服务。
llamafile:把模型权重和运行模型所需的所有内容捆绑在一个文件中,这样我们无需任何额外安装步骤,就能从此文件在本地运行LLM。
通常,这些框架会执行以下操作:
量化:减少原始模型权重的内存占用。
高效推理实现:支持在消费类硬件(例如CPU或笔记本电脑GPU)上进行推理。
- 有效加载LLM
现在,我们将探索如何通过几种(量化)标准加载本地LLM。由于存在分片、量化以及不同的保存和压缩策略,想要确定哪种方法适合自己并非易事。
在所有示例中,我们将使用Zephyr 7B,它是Mistral 7B的微调变体,使用直接偏好优化(DPO)进行训练。
🔥 提示:在每个加载LLM的示例之后,建议重新启动笔记本,以防止出现OutOfMemory错误。加载多个LLM需要大量的RAM/VRAM。我们可以通过删除模型并重置缓存来重置内存,具体操作如下:
del model, tokenizer, pipe
import torch
torch.cuda.empty_cache()
2.1 HuggingFace
加载LLM最直接、最常规的方法是通过🤗 Transformers。HuggingFace创建了一大套软件包,让我们能够使用LLM完成许多出色的任务!
我们首先从其主分支安装HuggingFace及相关依赖,以支持更新的模型:
!pip install git+https://github.com/huggingface/transformers.git
!pip install accelerate bitsandbytes xformers
安装完成后,我们可以使用以下管道轻松加载LLM:
from torch import bfloat16
from transformers import pipeline
pipe = pipeline(
“text-generation”,
model=“HuggingFaceH4/zephyr-7b-beta”,
torch_dtype=bfloat16,
device_map=“auto”
)
这种加载LLM的方法通常不会执行任何压缩技巧来节省VRAM或提高效率。
要生成提示(prompt),我们首先必须创建必要的模板。幸运的是,如果聊天模板保存在底层分词器中,就可以自动完成此操作:
messages = [
{
“role”: “system”,
“content”: “You are a friendly chatbot.”
},
{
“role”: “user”,
“content”: “Tell me a funny joke about Large Language Models.”
}
]
prompt = pipe.tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
使用内部提示模板生成的提示构造如下:
outputs = pipe(
prompt,
max_new_tokens=256,
do_sample=True,
temperature=0.1,
top_p=0.95
)
print(outputs[0][“generated_text”])
这将给我们带来以下输出: 为什么大型语言模型要参加派对? 为了社交并扩大它的词汇量!
这个笑点可能有点俗,但LLM的核心就在于扩大词汇量并与其他模型交流以提升语言技能。所以,这个笑话和它们很契合!
对于纯推理而言,这种方法通常效率最低,因为我们在加载整个模型时未采用任何压缩或量化策略。不过,它是一个很好的入门方法,因为它便于加载和使用模型!
2.2 LangChain
我们在本地运行LLM的另一种方式是使用LangChain。LangChain是一个用于构建AI应用程序的Python框架。它提供了抽象层和中间件,使我们能够在其支持的模型之一的基础上开发AI应用程序。例如,以下代码向microsoft/DialoGPT - medium模型提出一个问题:
from langchain.llms.huggingface_pipeline import HuggingFacePipeline
hf = HuggingFacePipeline.from_model_id(
model_id=“microsoft/DialoGPT-medium”, task=“text-generation”, pipeline_kwargs={“max_new_tokens”: 200, “pad_token_id”: 50256}
)
from langchain.prompts import PromptTemplate
template = “”“Question: {question}
Answer: Let’s think step by step.”“”
prompt = PromptTemplate.from_template(template)
chain = prompt | hf
question = “What is electroencephalography?”
print(chain.invoke({“question”: question}))
LangChain优点:
更便于模型管理。
为AI应用程序开发提供有用的实用程序。
LangChain缺点:
速度有限,与Transformers相同。
仍需对应用程序的逻辑进行编码或创建合适的UI。
2.3 Llama.cpp
Llama.cpp是一个基于C和C++的LLM推理引擎,针对Apple芯片进行了优化,可运行Meta的Llama2模型。
克隆存储库并构建项目后,我们可以使用以下命令运行模型:
$ ./main -m /path/to/model-file.gguf -p “Hi there!”
Llama.cpp优点:
比基于Python的解决方案性能更高。
在适度的硬件上支持Llama 7B等大型模型。
提供绑定,以便在通过Llama.cpp运行推理时,使用其他语言构建AI应用程序。
Llama.cpp缺点:
模型支持有限。
需要构建工具。
2.4 Llamafile
由Mozilla开发的Llamafile为运行LLM提供了一种用户友好的替代方案。Llamafile以其可移植性和创建单文件可执行文件的能力而闻名。
下载llamafile和任何GGUF格式的模型后,我们可以使用以下命令启动本地浏览器会话:
$ ./llamafile -m /path/to/model.gguf
Llamafile优点:
与Llama.cpp一样具有速度优势。
可以构建一个嵌入了模型的可执行文件。
Llamafile缺点:
该项目仍处于早期阶段。
并非支持所有模型,仅支持Llama.cpp支持的模型。
2.5 Ollama
Ollama是比Llama.cpp和Llamafile更用户友好的替代品。下载一个可执行文件,它会在您的机器上安装一个服务。安装完成后,打开终端并运行:
$ ollama run llama2
Ollama将下载模型并启动交互式会话。
Ollama优点:
易于安装和使用。
可以运行llama和vicuña模型。
速度非常快。
Ollama缺点:
提供的模型库有限。
自行管理模型,无法重复使用自己的模型。
运行LLM时没有可调选项。
目前还没有Windows版本。
2.6 GPT4ALL
GPT4ALL是一款易于使用的桌面应用程序,具有直观的图形用户界面(GUI)。它支持本地模型运行,并通过API密钥提供与OpenAI的连接。它以能够处理本地文档以获取上下文、确保隐私的能力而脱颖而出。
优点:
具有友好UI,是一个不错的选择。
支持一系列精选模型。
缺点:
型号选择有限。
某些型号存在商业用途限制。
2.7 分片
在讨论量化策略之前,还有另一种技巧可用于减少加载模型所需的VRAM,那就是分片。通过分片,我们实际上是将模型拆分成小块或分片。
每个分片包含模型的较小部分,旨在通过在不同设备之间分配模型权重来解决GPU内存限制问题。
我们加载的模型Zephyr - 7B - β已经为我们进行了分片。
虽然我们可以自己对模型进行分片,但通常建议留意量化模型,甚至自己进行量化。
使用Accelerate包进行分片非常简单:
from accelerate import Accelerator
accelerator = Accelerator()
accelerator.save_model(
model=pipe.model,
save_directory=“/content/model”,
max_shard_size=“4GB”
)
就是这样!因为我们将模型分片为4GB的块,而不是2GB,所以创建的加载文件更少。
2.8 用Bitsandbytes进行量化
LLM由一系列权重和激活值表示。这些值通常用常见的32位浮点(float32)数据类型表示。
比特数能表明它可以表示多少个值。Float32可以表示介于
到
之间的值,范围很广!比特数越低,能表示的值就越少。
正如我们所料,如果选择较低的比特大小,模型的准确性会降低,但同时它需要表示的值也更少,从而减小了模型的大小和内存需求。
量化指的是将LLM从其原始的Float32表示转换为更小的数据表示形式。然而,我们不只是简单地使用较小比特变体,而是要在不丢失太多信息的情况下,将较大比特表示映射到较小比特。
在实践中,我们经常会看到一种名为4bit - NormalFloat(NF4)的新格式用于量化。这种数据类型采用了一些特殊技巧来高效表示较大比特的数据类型,它包含三个步骤:
归一化:对模型的权重进行归一化处理,使我们期望权重落在特定范围内。这有助于更有效地表示常见值。
量化:将权重量化为4位。在NF4中,量化级别是根据归一化后的权重均匀分布的,从而有效地表示原始的32位权重。
反量化:尽管权重以4位存储,但在计算过程中会进行反量化,这在推理时能够提高性能 。
要使用HuggingFace进行这种量化,我们需要使用Bitsandbytes定义量化配置:
from transformers import BitsAndBytesConfig
from torch import bfloat16
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type=‘nf4’,
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=bfloat16
)
这个配置允许我们指定要采用的量化级别。通常,我们希望用4位量化表示权重,但在16位下进行推理。
然后,在管道中加载模型就很简单了:
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
tokenizer = AutoTokenizer.from_pretrained(“HuggingFaceH4/zephyr-7b-alpha”)
model = AutoModelForCausalLM.from_pretrained(
“HuggingFaceH4/zephyr-7b-alpha”,
quantization_config=bnb_config,
device_map=‘auto’
)
pipe = pipeline(model=model, tokenizer=tokenizer, task=‘text-generation’)
接下来,我们可以使用与之前相同的提示:
outputs = pipe(
prompt,
max_new_tokens=256,
do_sample=True,
temperature=0.7,
top_p=0.95
)
print(outputs[0][“generated_text”])
这将给我们带来以下输出: 为什么大型语言模型要参加派对?
量化是一种强大的技术,可在保持性能相似的同时减少模型的内存需求。它使得即使使用较小的GPU,也能够更快地加载、使用和微调LLM。
2.9 预量化(GPTQ、AWQ和GGUF的对比)
量化模型有多种不同的形式和大小。最值得注意的是,GPTQ、GGUF和AWQ格式是最常用于执行4位量化的格式。
GPTQ:GPT模型的训练后量化GPTQ是一种用于4位量化的训练后量化(PTQ)方法,主要侧重于GPU推理和性能优化。
该方法的核心思想是通过最小化与权重的均方误差,尝试将所有权重压缩为4位量化。在推理过程中,它会动态地将权重反量化为float16,以在保持低内存占用的同时提高性能。
如果想深入了解GPTQ的内部工作原理,一定要查看这篇文章:4-bit Quantization with GPTQ
我们首先安装在HuggingFace Transformers中加载类似GPTQ模型所需的一些包:
pip install optimum
pip install auto-gptq --extra-index-url https://huggingface.github.io/autogptq-index/whl/cu118/
安装完成后,我们可以导航到想要加载的模型,即“TheBloke/zephyr - 7B - beta - GPTQ”,并选择特定的版本。
这些版本本质上表明了量化方法、压缩级别、模型大小等信息。
from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline
model_id = “TheBloke/zephyr-7B-beta-GPTQ”
tokenizer = AutoTokenizer.from_pretrained(model_id, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(
model_id,
device_map=“auto”,
trust_remote_code=False,
revision=“main”
)
pipe = pipeline(model=model, tokenizer=tokenizer, task=‘text-generation’)
尽管我们安装了一些额外的依赖项,但仍然可以使用与之前相同的管道,这是使用GPTQ的一大优势。
加载模型后,我们可以按如下方式运行提示:
outputs = pipe(
prompt,
max_new_tokens=256,
do_sample=True,
temperature=0.1,
top_p=0.95
)
print(outputs[0][“generated_text”])
这将给我们带来以下生成的文本: 为什么大型语言模型要参加派对?
当然是为了展示它的智慧和魅力!
但不幸的是,它在人群中迷路了,找不到回到主人身边的路。派对参与者对它能如此无缝地融入人群的能力印象深刻,但大型语言模型却很困惑,只想回家。最后,一群人发现了它,认出了它独特的风格,并把它带回了它该在的地方。从那时起,大型语言模型在所有派对上都确保戴上名牌,只是为了安全起见。
GPTQ是最常用的压缩方法,因为它针对GPU使用进行了优化。如果GPU无法处理如此大的模型,从GPTQ开始,再切换到专注于CPU的方法(如GGUF)是值得的。
GGUF:GPT生成的统一格式虽然GPTQ的压缩效果很好,但如果我们的硬件有限,它对GPU的专注可能会成为一个缺点。
GGUF(以前称为GGML)是一种量化方法,允许用户使用CPU运行LLM,同时也可以将部分层卸载到GPU以提高速度。
虽然在推理时使用CPU通常比使用GPU慢,但对于在CPU或Apple设备上运行模型的人来说,这是一种非常出色的格式。特别是随着越来越小且功能越来越强大的模型(如Mistral 7B)的出现,GGUF格式可能会一直存在!
使用ctransformers包来使用GGUF相当简单,我们首先需要安装这个包:
pip install ctransformers[cuda]
安装完成后,我们可以导航到想要加载的模型,即“TheBloke/zephyr - 7B - beta - GGUF”,并选择特定的文件。
与GPTQ类似,这些文件表明了量化方法、压缩级别、模型大小等信息。
我们使用“zephyr - 7b - beta.Q4_K_M.gguf”,因为我们专注于4位量化:
from ctransformers import AutoModelForCausalLM
from transformers import AutoTokenizer, pipeline
model = AutoModelForCausalLM.from_pretrained(
“TheBloke/zephyr-7B-beta-GGUF”,
model_file=“zephyr-7b-beta.Q4_K_M.gguf”,
model_type=“mistral”, gpu_layers=50, hf=True
)
tokenizer = AutoTokenizer.from_pretrained(
“HuggingFaceH4/zephyr-7b-beta”, use_fast=True
)
pipe = pipeline(model=model, tokenizer=tokenizer, task=‘text-generation’)
加载模型后,我们可以按如下方式运行提示:
outputs = pipe(prompt, max_new_tokens=256)
print(outputs[0][“generated_text”])
这给我们带来了以下输出: 为什么大型语言模型要参加派对?为了用它的词汇量给每个人留下深刻印象!但不幸的是,它一遍又一遍地讲同样的笑话,让每个人都唉声叹气、翻白眼。派对参与者很快意识到,这个大型语言模型更像是个扫兴的人,而不是派对动物。故事的寓意是:仅仅因为一个大型语言模型能生成很多单词,并不意味着它知道如何变得有趣或有娱乐性。有时候,少即是多!
如果我们在GPU资源不足且没有最新最好的GPU时,想同时利用CPU和GPU,GGUF是一种很棒的格式。
AWQ:激活感知权重量化AWQ(Activation-aware Weight Quantization,激活感知权重量化)是一种新出现的格式,它是一种与GPTQ类似的量化方法。AWQ和GPTQ在方法上有一些区别,但最重要的一点是,AWQ认为并非所有权重对LLM的性能都同等重要。
换句话说,在量化过程中会跳过一小部分权重,这有助于减少量化损失。
因此,他们的论文提到,与GPTQ相比,AWQ在保持相似甚至有时更好的性能的同时,速度有显著提升。
这种方法仍然相对较新,尚未像GPTQ和GGUF那样被广泛采用,所以看看这些方法是否能够共存是很有趣的。
对于AWQ,我们将使用vLLM包,至少以我的经验来看,这是使用AWQ阻力最小的途径:
pip install vllm
有了vLLM,加载和使用我们的模型变得毫不费力:
from vllm import LLM, SamplingParams
sampling_params = SamplingParams(temperature=0.0, top_p=1.0, max_tokens=256)
llm = LLM(
model=“TheBloke/zephyr-7B-beta-AWQ”,
quantization=‘awq’,
dtype=‘half’,
gpu_memory_utilization=.95,
max_model_len=4096
)
然后,我们可以使用.generate轻松运行模型:
output = llm.generate(prompt, sampling_params)
print(output[0].outputs[0].text)
这给我们带来了以下输出: 为什么大型语言模型要参加派对?为了社交和扩大它的词汇量!为什么大型语言模型脸红了?因为它无意中听到另一个模型说它有点太啰嗦了!为什么大型语言模型被赶出了图书馆?因为它太吵了,不停地用没完没了的闲聊打断其他模型的对话!……
- 推理优化
堆叠变压器层以创建大型模型,能带来更高的准确率、少样本学习能力,甚至在广泛的语言任务中展现出接近人类的涌现能力。这些基础模型训练成本高昂,在推理时(持续成本)可能会占用大量内存和计算资源。如今,最受欢迎的大型语言模型(LLM)规模可达数十亿至数百亿参数,并且根据用例的不同,可能需要处理长输入(或上下文),这也会增加成本。例如,检索增强生成(RAG)管道需要将大量信息输入到模型中,大大增加了LLM必须完成的处理工作。
本文讨论了LLM推理中最紧迫的挑战以及一些实用的解决方案。读者应该对变压器架构和注意力机制有基本的了解。理解LLM推理的复杂性至关重要,我们将在下一节中讨论。
4. 理解LLM推理
大多数流行的仅解码器LLM(例如GPT-3)是在因果建模目标上进行预训练的,本质上是作为下一个单词预测器。这些LLM将一系列标记作为输入,并自回归地生成后续标记,直到满足停止条件(例如生成的标记数量限制或停止词列表),或者生成一个特殊的标记,表示生成结束。这个过程涉及两个阶段:预填充阶段和解码阶段。
请注意,标记是模型处理的语言基本单元。一个标记大约相当于四个英文字符。自然语言中的所有输入在输入到模型之前都要转换为标记。
4.1 预填充阶段或处理输入在预填充阶段,LLM处理输入标记以计算中间状态(键和值),这些中间状态用于生成 “第一个” 新标记。每个新标记都依赖于所有先前的标记,但由于输入的完整内容是已知的,从高层次来看,这是一个高度并行化的矩阵乘法操作。它有效地使GPU达到饱和利用状态。
4.2 解码阶段或生成输出在解码阶段,LLM自回归地一次生成一个输出标记,直到满足停止条件。每个连续的输出标记都需要知道之前所有迭代的输出状态(键和值)。这就像是一个矩阵向量操作,与预填充阶段相比,它没有充分利用GPU的计算能力。数据(权重、键、值、激活值)从内存传输到GPU的速度决定了延迟,而不是实际计算的速度。换句话说,这是一个受内存限制的操作。
本文中提到的许多推理挑战和相应的解决方案都涉及对这个解码阶段的优化:高效的注意力模块、有效地管理键和值等等。
不同的LLM可能使用不同的分词器,因此,在它们之间比较输出标记可能并不简单。在比较推理吞吐量时,即使两个LLM每秒输出的标记数量相似,但如果它们使用不同的分词器,它们也可能并不等效。这是因为相应的标记可能代表不同数量的字符。
4.3 请求批处理LLM服务的一个重要方面是对用户请求进行批处理。一种高效的方法是将参数一次性加载到GPU上,并利用它们一次性处理尽可能多的输入序列,而不是为每个新请求重新加载参数。这种方法不仅提高了服务器的吞吐量,优化了计算资源的利用,还显著提高了成本效益。然而,采用一种简单的方法,比如在处理批次之前等待固定数量的用户请求积累,会带来一些挑战。这意味着每个请求在批次内生成序列结束标记的时间不同。因此,批次计算速度受到最长生成时间的限制,导致用户等待时间(延迟)不理想。序列完成时间的差异导致GPU利用率不足,降低了批处理预期的效率提升。
静态批处理概述由于我们提到的所有这些挑战,连续批处理被提出来解决这些问题。
4.4 连续批处理连续批处理是一种专为LLM设计的批调度类型。与动态批处理(根据配置的时间阈值和最大批大小动态确定批大小)相比,连续批处理允许新请求在下一个解码器周期加入当前批次,而不是等待当前批次结束。由于LLM的自回归生成过程,这种方法对LLM很适用,并且可以显著提高模型的吞吐量。
**连续批处理概述
连续批处理对于动态批处理请求非常有用。然而,我们也面临另一个问题:内存限制。考虑我们的聊天机器人场景,一个用户可能用一个句子提出问题,而另一个用户可能向我们的应用程序发送一段文字,我们无法假设输入(和输出)序列的长度。这种不确定性给我们带来了内存消耗的关键问题。在不知道序列确切内存需求的情况下,人们不得不采用最坏情况的假设,为整个批次预留尽可能多的内存。
问题在于:GPU的内存是有限的,需要为以下两方面留出空间: - 模型参数 - 用户请求计算(KV缓存)以进行整个批次的计算
如果不进行优化,这些会占用大量空间,迫使我们缩小批大小,遗憾的是,这会降低吞吐量。但我们希望有高吞吐量。我们该如何优化呢?内存是关键。
让我们从内存的角度更深入地了解解码过程中发生了什么。LLM的生成过程从处理输入序列开始,并以自回归的方式逐个生成下一个标记(见下图)。这个生成过程包括自注意力计算,它需要目前为止处理的每个标记的所有键值(KV)分数计算。为了说明这一点,对于标记t的生成,我们需要来自标记t - 1、t - 2、…、1的计算出的键和值。为了优化递归计算,引入了KV缓存的概念。这种方法旨在将之前计算的标记的K和V张量存储在解码器中,随后在接下来的迭代中重用它们。然而,这种优化策略是以增加内存空间为代价的,当为了提高吞吐量而增大批大小时,这一点非常关键。由于序列长度不可预测,这个挑战更加严峻,导致传统的注意力机制由于碎片化和过度分配,会造成60% - 80%的显著内存浪费。
4.5 PagedAttention:以内存为中心的解决方案为了克服这个挑战,提出了PagedAttention。它从传统操作系统(OS)管理内存碎片化和共享的策略中获得灵感,PagedAttention使用带有分页的虚拟内存方法。它允许键和值向量存储在不连续的内存空间中。这使得键和值向量可以驻留在不连续的内存空间中,并组织成块。每个块容纳固定数量标记的注意力键和值。在执行计算时,PagedAttention内核会高效地识别并获取这些块。
4.6 键值缓存解码阶段的一种常见优化方法是KV缓存。解码阶段在每个时间步生成一个标记,但每个标记都依赖于所有先前标记的键和值张量(包括在预填充阶段计算的输入标记的KV张量,以及到当前时间步计算的任何新KV张量)。
为了避免在每个时间步为所有标记重新计算这些张量,可以将它们缓存在GPU内存中。每次迭代时,新计算的元素会简单地添加到运行中的缓存中,以供下一次迭代使用。在一些实现中,模型的每一层都有一个KV缓存。
4.6.1 LLM内存需求实际上,GPU上LLM内存需求的两个主要因素是模型权重和KV缓存。
模型权重:内存被模型参数占用。例如,一个有70亿参数的模型(如Llama 2 7B),以16位精度(FP16或BF16)加载,大约会占用70亿 * sizeof(FP16) ≈ 14GB的内存。
KV缓存:内存被自注意力张量的缓存占用,以避免冗余计算。
通过批处理,批次中每个请求的KV缓存仍然必须单独分配,并且可能会占用大量内存。下面的公式描述了KV缓存的大小,适用于当今大多数常见的LLM架构。 每个标记的KV缓存大小(以字节为单位) = 2 (层数)(头数 * 头维度)* 精度字节数
第一个因子2表示K和V矩阵。通常,(头数 * 头维度)的值与变压器的隐藏大小(或模型维度d_model)相同。这些模型属性通常可以在模型卡片或相关的配置文件中找到。
这个内存大小是输入序列中每个标记所需的,适用于一批输入。假设是半精度,KV缓存的总大小由以下公式给出。 KV缓存总大小(以字节为单位)=(批大小)(序列长度) 2 (层数)(隐藏大小)* sizeof(FP16)
例如,对于一个16位精度的Llama 2 7B模型,批大小为1,KV缓存的大小将是1 * 4096 * 2 * 32 * 4096 * 2字节,约为2GB。
有效地管理这个KV缓存是一项具有挑战性的任务。由于内存需求随着批大小和序列长度线性增长,它可能会迅速扩展。因此,它限制了可以提供的吞吐量,并对长上下文输入带来了挑战。这就是本文中介绍的几种优化方法的动机。
5. 通过模型并行化扩展大语言模型
减少每个设备上模型权重内存占用的一种方法是将模型分布在多个GPU上。分散内存和计算负载能够运行更大的模型,或者处理更大批次的输入。对于需要比单个设备上可用内存更多内存的模型进行训练或推理,以及使训练时间和推理指标(延迟或吞吐量)适合特定的用例来说,模型并行化是必要的。根据模型权重的分割方式,有几种并行化模型的方法。
需要注意的是,数据并行也是一种常与下面列出的其他方法同时提及的技术。在数据并行中,模型的权重被复制到多个设备上,输入的(全局)批次大小被分割到每个设备上成为微批次。它通过处理更大的批次来减少整体执行时间。然而,这是一种训练时间的优化方法,在推理过程中不太相关。
5.1 流水线并行
流水线并行是将模型(垂直地)分割成块,每个块包含一组在单独设备上执行的层。图2a展示了四路流水线并行,模型被顺序分区,所有层的四分之一子集在每个设备上执行。一个设备上一组操作的输出会传递到下一个设备,下一个设备继续执行后续的块。
和分别表示在设备
上的前向和后向传递。在每个设备上存储模型权重所需的内存有效地减少到了原来的四分之一。
这种方法的主要限制是,由于处理的顺序性,一些设备或层在等待前一层的输出(激活值、梯度)时可能会处于空闲状态。这导致在前向和后向传递中都出现效率低下的情况,也就是 “流水线气泡”。在图2b中,白色的空白区域就是简单流水线并行中的大 “流水线气泡”,设备处于空闲和未充分利用的状态。
微批次处理可以在一定程度上缓解这个问题,如图2c所示。输入的全局批次大小被分割成子批次,这些子批次被逐个处理,梯度在最后进行累加。注意,
和分别表示在设备上对微批次的前向和后向传递。这种方法缩小了 “流水线气泡” 的大小,但并不能完全消除它们。
5.2 张量并行
张量并行是将模型的各个层(水平地)分割成更小的、独立的计算块,这些计算块可以在不同的设备上执行。注意力块和多层感知器(MLP)层是可以利用张量并行的Transformer的主要组件。在多头注意力块中,每个头或一组头可以分配到不同的设备上,这样它们就可以独立并行地计算。
图3展示了在多层感知器(MLP)和自注意力层中的张量并行示例。图3a展示了在一个两层MLP上的两路张量并行示例,每一层用一个圆角框表示。在第一层中,权重矩阵
被分割成和。计算和可以在两个不同的设备上对同一批次的输入独立执行(是恒等操作)。这有效地将每个设备上存储权重所需的内存减少了一半。在第二层中,通过归约操作
组合输出。
图3b是自注意力层中两路张量并行的示例。多个注意力头本质上是并行的,可以跨设备分割。
5.3 序列并行
张量并行有其局限性,因为它要求将层划分为独立的、可管理的块。它不适用于像LayerNorm和Dropout这样的操作,这些操作会在张量并行组中被复制。虽然LayerNorm和Dropout的计算成本不高,但它们确实需要大量内存来存储(冗余的)激活值。
如《Reducing Activation Recomputation in Large Transformer Models》中所示,这些操作在输入序列上是相互独立的,并且这些操作可以沿着 “序列维度” 进行分区,从而提高内存效率。这被称为序列并行。
模型并行化的技术不是相互排斥的,可以结合使用。它们可以帮助扩展大语言模型并减少每个GPU的内存占用,但也有专门针对注意力模块的优化技术。
6. 优化注意力机制
缩放点积注意力(SDPA)操作将查询和键值对映射到一个输出,如《Attention Is All You Need》中所述。
6.1 多头注意力
作为对SDPA的增强,多头注意力通过对查询(Q)、键(K)和值(V)矩阵进行不同的、可学习的投影,并行地多次执行注意力层,使模型能够同时关注来自不同表示子空间不同位置的信息。这些子空间是独立学习的,为模型提供了对输入中不同位置更丰富的理解。
如图5所示,多个并行注意力操作的输出被连接起来并进行线性投影以组合它们。每个并行注意力层被称为一个 “头”,这种方法被称为多头注意力(MHA)。在原始工作中,当使用八个并行注意力头时,每个注意力头在模型的较低维度上操作(例如 )。这使得计算成本与单头注意力相似。
从实验中获得的简要概述:
内存是关键:内存分配的管理对于优化LLM性能至关重要。
抢占是一种策略性权衡:对于像vLLM这样的引擎来说,由于生成操作受内存限制,而GPU未被充分利用,因此抢占是一种策略性的权衡。
序列长度的影响:序列长度的研究揭示了vLLM在处理并发请求方面的效率,特别是在输出较短的情况下。
模型大小对吞吐量的影响:模型大小对吞吐量有显著影响。然而,超过一定程度后,额外的GPU内存不再能提高吞吐量。
服务器选择的重要性:服务器的选择起着至关重要的作用,正如白皮书中Triton搭配TensorRT-LLM的表现优于独立的TensorRT-LLM所证明的那样。
尽管用于LLMs推理的框架众多,但每个框架都有其特定的用途。以下是一些需要考虑的要点:
追求速度时选择vLLM:当需要以最大速度进行批处理提示交付时,使用vLLM。
需要HuggingFace原生支持时选择文本生成推理:如果需要HuggingFace的原生支持,且不打算为核心模型使用多个适配器,选择文本生成推理。
在CPU上运行推理时选择CTranslate2:如果速度对我们很重要,并且打算在CPU上运行推理,考虑CTranslate2。
使用适配器和HuggingFace智能体时选择OpenLLM:如果想为核心模型连接适配器并使用HuggingFace智能体,尤其是不单纯依赖PyTorch时,选择OpenLLM。
追求稳定和灵活部署时选择Ray Serve:对于稳定的流程和灵活的部署,考虑Ray Serve。它最适合更成熟的项目。
在客户端进行原生部署时选择MLC LLM:如果想在客户端(边缘计算),如Android或iPhone平台上原生部署LLMs,使用MLC LLM。
基于DeepSpeed库进行部署时选择DeepSpeed-MII:如果已经有使用DeepSpeed库的经验,并希望继续使用它来部署LLMs,选择DeepSpeed-MII。