使用缓存优化生成的最佳实践¶
高效的缓存对于优化各种生成任务(包括文本生成、翻译、摘要和其他基于 Transformer 的应用程序)中模型的性能至关重要。有效的缓存有助于减少计算时间并提高响应速度,特别是在实时或资源密集型应用程序中。
Transformers 支持各种缓存方法,利用“Cache”类来抽象和管理缓存逻辑。本文概述了使用这些类以最大化性能和效率的最佳实践。请查看 API 文档 中所有可用的 Cache 类。
什么是缓存以及为什么我们应该关注?¶
想象一下,你正在与某人交谈,而不是记住之前所说的内容,每次你回应时都必须从头开始。这会很慢且效率低下,对吧?在 Transformer 模型的世界中,有一个类似的概念,那就是键值缓存(KV Cache)的应用。
KV 缓存对于优化自回归模型中的生成是必要的,在这种模型中,模型通过逐个预测文本来生成。这个过程可能很慢,因为模型一次只能生成一个标记,并且每个新的预测都依赖于先前的上下文。这意味着,要在生成中预测第 1000 个标记,你需要来自前 999 个标记的信息,这些信息以对这些标记的表示进行一些矩阵乘法的形式出现。但是,要预测第 1001 个标记,你还需要来自前 999 个标记的相同信息,以及来自第 1000 个标记的额外信息。这就是键值缓存用于优化顺序生成过程的地方,通过存储先前的计算结果以在后续标记中重用,这样它们就不需要再次计算。
更具体地说,键值缓存充当这些生成模型的内存库,模型存储来自先前处理标记的自注意力层的键值对。通过存储这些信息,模型可以避免冗余计算,而是从缓存中检索先前标记的键和值。请注意,缓存只能在推理中使用,在训练时应禁用,否则可能会导致意外的错误。
对于喜欢深入研究的读者
缓存对象如何在注意力机制中工作¶
当在输入中使用缓存对象时,注意力模块执行几个关键步骤,以无缝地整合过去和现在的信息。
注意力模块将当前键值与存储在缓存中的过去键值连接起来。这导致注意力权重形状为 (new_tokens_length, past_kv_length + new_tokens_length)。本质上,过去和当前的键值被组合起来计算注意力分数,确保模型考虑先前的上下文和新的输入。连接的键值用于计算注意力分数,从而产生形状为 (new_tokens_length, past_kv_length + new_tokens_length) 的注意力权重。
因此,当迭代调用 forward() 而不是 generate() 方法时,确保注意力掩码的形状与过去和当前键值的组合长度相匹配至关重要。注意力掩码的形状应该是 (batch_size, past_kv_length + new_tokens_length)。当你调用 generate() 方法时,这通常由内部处理。如果你想要使用 Cache 类实现自己的生成循环,请考虑这一点,并为当前和过去标记准备注意力掩码。
编写自己的生成循环时,你需要了解的一个重要概念是 cache_position。如果你想要通过调用 forward() 来重用已经填充的 Cache 对象,你必须传入一个有效的 cache_position,这将指示序列中输入的位置。请注意,cache_position 不受填充的影响,并且总是为每个标记添加一个位置。例如,如果键/值缓存包含 10 个标记(无论其中有多少是填充标记),下一个标记的缓存位置应该是 torch.tensor([10])。
下面是如何实现自己的生成循环的示例。
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, DynamicCache
model_id = "meta-llama/Llama-2-7b-chat-hf"
model = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype=torch.bfloat16, device_map="cuda:0")
tokenizer = AutoTokenizer.from_pretrained(model_id)
past_key_values = DynamicCache()
messages = [{"role": "user", "content": "Hello, what's your name."}]
inputs = tokenizer.apply_chat_template(messages, add_generation_prompt=True, return_tensors="pt", return_dict=True).to("cuda:0")
generated_ids = inputs.input_ids
cache_position = torch.arange(inputs.input_ids.shape[1], dtype=torch.int64, device="cuda:0")
max_new_tokens = 10
for _ in range(max_new_tokens):
outputs = model(**inputs, cache_position=cache_position, past_key_values=past_key_values, use_cache=True)
# Greedily sample one next token
next_token_ids = outputs.logits[:, -1:].argmax(-1)
generated_ids = torch.cat([generated_ids, next_token_ids], dim=-1)
# Prepare inputs for the next generation step by leaving unprocessed tokens, in our case we have only one new token
# and expanding attn mask for the new token, as explained above
attention_mask = inputs["attention_mask"]
attention_mask = torch.cat([attention_mask, attention_mask.new_ones((attention_mask.shape[0], 1))], dim=-1)
inputs = {"input_ids": next_token_ids, "attention_mask": attention_mask}
cache_position = cache_position[-1:] + 1 # add one more position for the next token
print(tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0])
使用缓存生成¶
在 🤗 Transformers 中,我们支持各种缓存类型来优化不同模型和任务中的性能。默认情况下,所有模型都使用缓存生成,其中 ~DynamicCache 类是大多数模型的默认缓存。它允许我们动态地增长缓存大小,通过在生成过程中保存越来越多的键和值。如果你出于某种原因不想使用缓存,你可以在 generate() 方法中传入 use_cache=False。
请参考下表以了解缓存类型之间的区别,并选择最适合你用例的缓存。建议在调用模型之前初始化的模型应该作为 kwarg 传递给模型。在所有其他情况下,你可以简单地定义所需的 cache_implementation,我们负责处理其余的事情。
| 缓存类型 | 内存效率 | 支持 torch.compile() | 建议初始化 | 延迟 | 长上下文生成 |
|---|---|---|---|---|---|
| 动态缓存 | 否 | 否 | 否 | 中等 | 否 |
| 静态缓存 | 否 | 是 | 是 | 高 | 否 |
| 卸载缓存 | 是 | 否 | 否 | 低 | 是 |
| 卸载静态缓存 | 否 | 是 | 是 | 高 | 是 |
| 量化缓存 | 是 | 否 | 否 | 低 | 是 |
| 滑动窗口缓存 | 否 | 是 | 是 | 高 | 否 |
| 汇缓存 | 是 | 否 | 是 | 中等 | 是 |
这些缓存类可以在生成时通过 cache_implementation 参数设置。要了解 cache_implementation 标志的可用选项,请参阅 API 文档。现在,让我们详细探讨每种缓存类型,并了解如何使用它们。请注意,下面的示例适用于仅解码器 Transformer 模型。我们也支持特定于模型的缓存类,例如 Mamba 或 Jamba,继续阅读以了解更多细节。
量化缓存¶
键和值缓存可能占用大量内存,成为 长上下文生成的瓶颈,特别是对于大型语言模型。在使用 generate() 时量化缓存可以显著降低内存需求,但代价是速度。
transformers 中的 KV 缓存量化很大程度上受到论文 “KIVI: A Tuning-Free Asymmetric 2bit Quantization for KV Cache” 的启发,目前支持 ~QuantoQuantizedCache 和 ~HQQQuantizedCache 类。有关内部工作原理的更多信息,请参阅该论文。
要启用键值缓存的量化,需要在 generation_config 中指示 cache_implementation="quantized"。量化相关的参数应该作为 dict 或 ~QuantizedCacheConfig 类的实例传递给 generation_config。你必须指示在 ~QuantizedCacheConfig 中使用哪个量化后端,默认是 quanto。
如果你使用的是 quanto 后端,建议将 axis-key/axis-value 参数设置为 0;如果你使用的是 HQQ 后端,则设置为 1。对于其他配置值,请使用默认值,除非你内存不足。在这种情况下,你可能需要考虑减少残差长度。
缓存量化可能在延迟方面有害,如果上下文长度短且 GPU VRAM 足够运行而不需要缓存量化。建议在内存效率和延迟之间寻求平衡。
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-chat-hf")
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-chat-hf", torch_dtype=torch.float16).to("cuda:0")
inputs = tokenizer("I like rock music because", return_tensors="pt").to(model.device)
out = model.generate(**inputs, do_sample=False, max_new_tokens=20, cache_implementation="quantized", cache_config={"nbits": 4, "backend": "quanto"})
print(tokenizer.batch_decode(out, skip_special_tokens=True)[0])
out = model.generate(**inputs, do_sample=False, max_new_tokens=20)
print(tokenizer.batch_decode(out, skip_special_tokens=True)[0])
卸载缓存¶
与 KV 缓存量化类似,~OffloadedCache 策略旨在减少 GPU VRAM 使用。它通过将大多数层的 KV 缓存移动到 CPU 来实现这一点。当模型的 forward() 方法迭代层时,此策略在 GPU 上维护当前层缓存。同时,它异步地预取下一层缓存,并将前一层缓存发送回 CPU。与 KV 缓存量化不同,此策略总是产生与默认 KV 缓存实现相同的结果。因此,它可以作为替换或备用方案。
根据你的模型和生成任务的特征(上下文大小、生成的标记数、光束数等),你可能会注意到与默认 KV 缓存实现相比,生成吞吐量略有下降。
要启用 KV 缓存卸载,请在 generation_config 中传递 cache_implementation="offloaded",或者直接传递给 generate() 调用。使用 cache_implementation="offloaded_static" 用于卸载静态缓存(也请参阅下面的 卸载静态缓存)。
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
ckpt = "microsoft/Phi-3-mini-4k-instruct"
tokenizer = AutoTokenizer.from_pretrained(ckpt)
model = AutoModelForCausalLM.from_pretrained(ckpt, torch_dtype=torch.float16).to("cuda:0")
inputs = tokenizer("Fun fact: The shortest", return_tensors="pt").to(model.device)
out = model.generate(**inputs, do_sample=False, max_new_tokens=23, cache_implementation="offloaded")
print(tokenizer.batch_decode(out, skip_special_tokens=True)[0])
out = model.generate(**inputs, do_sample=False, max_new_tokens=23)
print(tokenizer.batch_decode(out, skip_special_tokens=True)[0])
缓存卸载需要一个 GPU,并且可能比动态 KV 缓存慢。如果你遇到 CUDA 内存不足错误,请使用它。
下面的示例显示了如何将 KV 缓存卸载用作备用策略。
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
def resilient_generate(model, *args, **kwargs):
oom = False
try:
return model.generate(*args, **kwargs)
except torch.cuda.OutOfMemoryError as e:
print(e)
print("retrying with cache_implementation='offloaded'")
oom = True
if oom:
torch.cuda.empty_cache()
kwargs["cache_implementation"] = "offloaded"
return model.generate(*args, **kwargs)
ckpt = "microsoft/Phi-3-mini-4k-instruct"
tokenizer = AutoTokenizer.from_pretrained(ckpt)
model = AutoModelForCausalLM.from_pretrained(ckpt, torch_dtype=torch.float16).to("cuda:0")
prompt = ["okay "*1000 + "Fun fact: The most"]
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
beams = { "num_beams": 40, "num_beam_groups": 40, "num_return_sequences": 40, "diversity_penalty": 1.0, "max_new_tokens": 23, "early_stopping": True, }
out = resilient_generate(model, **inputs, **beams)
responses = tokenizer.batch_decode(out[:,:-28:], skip_special_tokens=True)
在拥有 50 GB RAM 的 GPU 上运行此代码将打印:
""" CUDA out of memory. Tried to allocate 4.83 GiB. GPU retrying with cache_implementation='offloaded' """
然后成功生成 40 个光束。
静态缓存¶
由于“DynamicCache”会随着每个生成步骤动态增长,它阻止你利用 JIT 优化。~StaticCache 为键和值预先分配一个特定的最大大小,允许你生成到最大长度,而无需修改缓存大小。检查下面的使用示例。
有关带有静态缓存和 JIT 编译的更多示例,请查看 StaticCache & torchcompile
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-chat-hf")
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-chat-hf", torch_dtype=torch.float16, device_map="auto")
inputs = tokenizer("Hello, my name is", return_tensors="pt").to(model.device)
# simply pass the cache implementation="static"
out = model.generate(**inputs, do_sample=False, max_new_tokens=20, cache_implementation="static")
tokenizer.batch_decode(out, skip_special_tokens=True)[0]
卸载静态缓存¶
与 ~OffloadedCache 存在用于卸载“DynamicCache”一样,也存在卸载静态缓存。它完全支持 JIT 优化。只需在 generation_config 或直接传递给 generate() 调用时传递 cache_implementation="offloaded_static"。这将使用 ~OffloadedStaticCache 实现。
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-chat-hf")
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-chat-hf", torch_dtype=torch.float16, device_map="auto")
inputs = tokenizer("Hello, my name is", return_tensors="pt").to(model.device)
# simply pass the cache implementation="offloaded_static"
out = model.generate(**inputs, do_sample=False, max_new_tokens=20, cache_implementation="offloaded_static")
tokenizer.batch_decode(out, skip_special_tokens=True)[0]
滑动窗口缓存¶
顾名思义,这种缓存类型在先前的键和值上实现了一个滑动窗口,只保留最后 sliding_window 个标记。它应该与支持滑动窗口注意力的模型(如 Mistral)一起使用。此外,与静态缓存类似,这个缓存也是 JIT 友好的,并且可以使用与静态缓存相同的编译技术。
注意,你只能对支持滑动窗口的模型使用此缓存,例如 Mistral 模型。
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, SinkCache
tokenizer = AutoTokenizer.from_pretrained("mistralai/Mistral-7B-v0.1")
model = AutoModelForCausalLM.from_pretrained("mistralai/Mistral-7B-v0.1", torch_dtype=torch.float16).to("cuda:0")
inputs = tokenizer("Yesterday I was on a rock concert and.", return_tensors="pt").to(model.device)
# can be used by passing in cache implementation
out = model.generate(**inputs, do_sample=False, max_new_tokens=30, cache_implementation="sliding_window")
tokenizer.batch_decode(out, skip_special_tokens=True)[0]
汇缓存¶
汇缓存是在 “Efficient Streaming Language Models with Attention Sinks” 中引入的。它允许你生成长文本序列(根据论文是“无限长度”),而无需任何微调。这是通过智能处理先前的键和值来实现的,具体来说,它保留序列中的一些初始标记,称为“汇标记”。这是基于观察,这些初始标记在生成过程中吸引了大部分注意力分数。在“汇标记”之后出现的标记以滑动窗口的方式被丢弃,只保留最新的 window_size 个标记。通过将这些初始标记作为“注意力汇”,模型即使在处理非常长的文本时也能保持稳定的性能,从而丢弃了大部分先前的知识。
与其他缓存类不同,这个类不能通过指示 cache_implementation 直接使用。你必须在调用 generate() 之前初始化 Cache,如下所示。
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, SinkCache
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-chat-hf")
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-chat-hf", torch_dtype=torch.float16).to("cuda:0")
inputs = tokenizer("This is a long story about unicorns, fairies and magic.", return_tensors="pt").to(model.device)
# get our cache, specify number of sink tokens and window size
# Note that window size already includes sink tokens, so has to be larger
past_key_values = SinkCache(window_length=256, num_sink_tokens=4)
out = model.generate(**inputs, do_sample=False, max_new_tokens=30, past_key_values=past_key_values)
tokenizer.batch_decode(out, skip_special_tokens=True)[0]
编码器-解码器缓存¶
~EncoderDecoderCache 是一个包装器,旨在处理编码器-解码器模型的缓存需求。这种缓存类型专门构建来管理自注意力和交叉注意力缓存,确保存储和检索这些复杂模型所需的过去键/值。编码器-解码器缓存的一个很酷的特性是,你可以为编码器和解码器设置不同的缓存类型,具体取决于你的用例。目前这种缓存仅在 Whisper 模型中支持,但我们很快会添加更多模型。
在用法方面,没有什么特别的操作,调用 generate() 或 forward() 将处理所有事情。
模型特定缓存类¶
某些模型需要以特定方式存储先前的键、值或状态,上述缓存类无法使用。对于此类情况,我们有几个专门为特定模型设计的缓存类。这些模型只接受它们自己的专用缓存类,并且不支持使用任何其他缓存类型。一些例子包括 ~HybridCache 用于 Gemma2 系列模型或 ~MambaCache 用于 Mamba 架构模型。
使用缓存进行迭代生成¶
我们已经看到了如何在生成时使用每种缓存类型。如果你想在迭代生成设置中使用缓存,例如在聊天机器人等应用程序中,其中交互涉及多个回合和持续的来回交流,该怎么办?使用缓存进行迭代生成允许这些系统有效地处理持续的对话,而无需在每一步重新处理整个上下文。但在你开始实现之前,有一些提示你应该知道:
进行迭代生成的一般格式如下。首先,你必须初始化一个你想要的类型的空缓存,然后你可以开始迭代地输入新的提示。对话历史记录和格式的跟踪可以通过聊天模板来完成,更多内容请阅读 chat_templating。
如果你使用的是汇缓存,你必须将输入裁剪到最大长度,因为汇缓存可以生成比其最大窗口长度更长的文本,但它期望第一个输入不超过最大缓存长度。
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
from transformers.cache_utils import (
DynamicCache,
SinkCache,
StaticCache,
SlidingWindowCache,
QuantoQuantizedCache,
QuantizedCacheConfig,
)
model_id = "meta-llama/Llama-2-7b-chat-hf"
model = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype=torch.bfloat16, device_map='auto')
tokenizer = AutoTokenizer.from_pretrained(model_id)
user_prompts = ["Hello, what's your name?", "Btw, yesterday I was on a rock concert."]
past_key_values = DynamicCache()
max_cache_length = past_key_values.get_max_length()
messages = []
for prompt in user_prompts:
messages.append({"role": "user", "content": prompt})
inputs = tokenizer.apply_chat_template(messages, add_generation_prompt=True, return_tensors="pt", return_dict=True).to(model.device)
if isinstance(past_key_values, SinkCache):
inputs = {k: v[:, -max_cache_length:] for k, v in inputs.items()}
input_length = inputs["input_ids"].shape[1]
outputs = model.generate(**inputs, do_sample=False, max_new_tokens=256, past_key_values=past_key_values)
completion = tokenizer.decode(outputs[0, input_length:], skip_special_tokens=True)
messages.append({"role": "assistant", "content": completion})
print(messages)
重新使用缓存以继续生成¶
有时你可能想要首先用键/值填充缓存对象以用于某些前缀提示,并多次重新使用它以生成不同的序列。在这种情况下,你可以构建一个 Cache 对象来保存指令提示,并多次使用不同的文本序列重新使用它。
import copy
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, DynamicCache, StaticCache
model_id = "meta-llama/Llama-2-7b-chat-hf"
model = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype=torch.bfloat16, device_map="cuda")
tokenizer = AutoTokenizer.from_pretrained(model_id)
# 初始化具有足够大最大长度的 StaticCache(下面的示例中为 1024 个标记)
# 如果更适合你的需求,你也可以初始化一个 DynamicCache
prompt_cache = StaticCache(config=model.config, max_batch_size=1, max_cache_len=1024, device="cuda", dtype=torch.bfloat16)
INITIAL_PROMPT = "You are a helpful assistant. "
inputs_initial_prompt = tokenizer(INITIAL_PROMPT, return_tensors="pt").to("cuda")
# 这是公共提示缓存,我们需要在没有梯度的情况下运行 forward 来复制
with torch.no_grad():
prompt_cache = model(**inputs_initial_prompt, past_key_values = prompt_cache).past_key_values
prompts = ["Help me to write a blogpost about travelling.", "What is the capital of France?"]
responses = []
for prompt in prompts:
new_inputs = tokenizer(INITIAL_PROMPT + prompt, return_tensors="pt").to("cuda")
past_key_values = copy.deepcopy(prompt_cache)
outputs = model.generate(**new_inputs, past_key_values=past_key_values, max_new_tokens=20)
response = tokenizer.batch_decode(outputs)[0]
responses.append(response)
print(responses)
遗留缓存格式¶
在引入 Cache 对象之前,LLM 的缓存曾经是一个元组的元组,其中包含张量。遗留格式具有动态大小,随着我们生成文本而增长——与 DynamicCache 非常相似。如果你的项目依赖于这种遗留格式,你可以无缝地将其转换为 DynamicCache,反之亦然。
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, DynamicCache
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-chat-hf")
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-chat-hf", torch_dtype=torch.float16, device_map="auto")
inputs = tokenizer("Hello, my name is", return_tensors="pt").to(model.device)
# `return_dict_in_generate=True` 是返回缓存所必需的。`return_legacy_cache` 强制返回的缓存
# 为遗留类型
generation_outputs = model.generate(**inputs, return_dict_in_generate=True, return_legacy_cache=True, max_new_tokens=5)
# 我们可以将遗留缓存转换为 DynamicCache —— 反之亦然。如果你有特定的格式来操作缓存的自定义逻辑,这很有帮助。
cache = DynamicCache.from_legacy_cache(generation_outputs.past_key_values)
legacy_format_cache = cache.to_legacy_cache()