从多少显存可以运行一个7B模型开始聊起
常常有人会聊一个问题,你如果要能够运行一个7B的模型,显卡需要多少显存才够。
感觉这个话题是很有意思的,如果你具体问题具体分析,可以先反问对方,FP8量化还是FP16量化,这时候对面当然可以反讥你,这么小的模型还需要量化?
当然我们这篇博客的重点并不在这个部分,我们先开始从传统的方式来回答这个问题,这个模型是FP16的,基础知识会告诉我们,FP16的模型,每个参数是2Byte,所以7B的模型需要14G的显存。当然,这里的显存仅仅是把模型权重加载到显存上所需要占据的显存,实际计算过程中,仍然有相当一部分需要显存占用。
这部分的内容主要包括:KV Cache、中间张量、框架开销和碎片化显存等。中间张量是可以理解的,因为计算一定会产生中间输出,中间输出在计算结束前,计算图并不会直接消失,因而结果需要空间存储。框架开销和碎片化显存也可以理解,一般一些triton算子计算的输入物理地址需要连续,所以会有这样的要求。
那么KV Cache的存在,到底是为了什么呢?其实从名字就能看出来,缓存的目的无非就是一条,用空间换时间。
那么在讨论KV Cache需要占用多少显存之前,我们需要讨论这样一个话题,为什么能用空间换时间。也就是说,KV Cache为什么能够生效。
验证KV Cache正确性
关于KV Cache,从名字就能看出来,是一个用来存储key和value的缓存,也有很多人会讲,MHA可以通过缓存KV的方式来减少实际运算,但很少有人真正讲明白,缓存的是什么KV矩阵,不需要运算的又是什么KV矩阵。这一章,我们从你的输入如何变为输出结果来看为何KV Cache能够生效,KV Cache生效又能带来多少增益。
输入《中国人能飞吗?》之后大模型会输出什么
假设,我们这里并不讨论sft之后的大模型,我们只考虑原本经过原始预训练的模型,然后这个预训练的模型样本库里有这样一句话《中国人能飞吗?当然可以!》,并且模型很好的收敛了。
假设我们模型的架构是 input->MHA->MHA->output,也就是用两个MHA来模拟网络
那么此时,我们将《中国人能飞吗?》作为输入,首先经过tokenizer,进行最初的分词以及最简单的embedding,假设模型预设的最大输入窗口是1024个token,大概这句话能够获得这样的结果,为什么mask是全1呢,因为这个batch只有一条数据,不需要对齐,当然哈,我们这里不讨论分词器,假设一个中文占据一个token。
import torch
embedding = torch.randn(1, 7, 512) # B, L, D
mask = torch.ones(1, 7, dtype=torch.long) # B, L
之后,对于你的输入的embedding,模型会首先进行prefill的运算,也就是对于这一批的数据,一次性并行计算整个Prompt序列的特征,而不是像生成阶段那样逐字递推,但是为了防止因果关系影响编码,为了确保第i个token不会偷看到第i+1个及以后的token,我们需要引入因果掩码,记为:
mask_causal = torch.triu(torch.full((L, L), float('-inf')), diagonal=1)
"""
等价于
mask_causal = torch.tensor([
[ 0., -inf, -inf, -inf, -inf, -inf, -inf],
[ 0., 0., -inf, -inf, -inf, -inf, -inf],
[ 0., 0., 0., -inf, -inf, -inf, -inf],
[ 0., 0., 0., 0., -inf, -inf, -inf],
[ 0., 0., 0., 0., 0., -inf, -inf],
[ 0., 0., 0., 0., 0., 0., -inf],
[ 0., 0., 0., 0., 0., 0., 0.]
])
"""
介绍完这些之后,我们先实现一个不带KV Cache的正经MHA,有趣的是,如果没有KV Cache,你甚至意识不到这个阶段在进行prefill,因为每一次计算都是一次prefill
import torch
import torch.nn as nn
import torch.nn.functional as F
class MultiHeadAttentionNoCache(nn.Module):
def __init__(self, hidden_dim=512, num_heads=8):
super().__init__()
self.hidden_dim = hidden_dim
self.num_heads = num_heads
self.head_dim = hidden_dim // num_heads
# 定义投影矩阵
self.W_q = nn.Linear(hidden_dim, hidden_dim, bias=False) # 偏置项为什么为0这里留到下一篇博客
self.W_k = nn.Linear(hidden_dim, hidden_dim, bias=False)
self.W_v = nn.Linear(hidden_dim, hidden_dim, bias=False)
self.W_o = nn.Linear(hidden_dim, hidden_dim, bias=False)
def forward(self,x):
B, L, D = x.shape #[1, 7, 512]
q = self.W_q(x).view(B, L, self.num_heads, self.head_dim).transpose(1, 2) # [1, 8, 7, 64]
"""
这里我们拆看看下q的计算过程
q = self.W_q(x) # [1, 7, 512]
q = q.view(B, L, self.num_heads, self.head_dim).transpose(1, 2) # [1, 8, 7, 64]
"""
k = self.W_k(x).view(B, L, self.num_heads, self.head_dim).transpose(1, 2) # [1, 8, 7, 64]
v = self.W_v(x).view(B, L, self.num_heads, self.head_dim).transpose(1, 2) # [1, 8, 7, 64]
# 计算注意力得分
scores = torch.matmul(q, k.transpose(-2, -1)) / torch.sqrt(torch.tensor(self.head_dim)) # [1, 8, 7, 7]
"""
[1, 8, 7, 64] * [1, 8, 64, 7]/√64 = [1, 8, 7, 7]
"""
# 构造因果矩阵
mask_causal = torch.triu(torch.full((L, L), float('-inf'), device=x.device), diagonal=1) # [7, 7]
# 加上就行了,右上角会被置为负无穷,->0,(这里非常重要!!,也是KV Cache能生效的原因)
scores = scores + mask_causal
attn_weights = F.softmax(scores, dim=-1) # [1, 8, 7, 7]
# 计算输出
out = torch.matmul(attn_weights, v) # [1, 8, 7, 64]
# 拼接多头,还原输出,计算投影
out = out.transpose(1, 2).contiguous().view(B, L, D) # [1, 7, 512]
out = self.W_o(out) # [1, 7, 512]
return out
构造完成多头注意力之后,我们就构造个网络,这个网络实在太简单了,直接写出来即可,包括预测的Head
class SimpleLLMNoCache(nn.Module):
def __init__(self, hidden_dim=512, num_heads=8, vocab_size=10000):
super().__init__()
# 两层 MHA
self.layer1 = MultiHeadAttentionNoCache(hidden_dim, num_heads)
self.layer2 = MultiHeadAttentionNoCache(hidden_dim, num_heads)
# 预测下一个词的 LM Head,将 512 维度映射到 10000 词表大小
self.lm_head = nn.Linear(hidden_dim, vocab_size, bias=False)
def forward(self, x):
"""
x: [B, L, D] -> [1, L, 512]
"""
# 第一层 MHA
h1 = self.layer1(x) # [1, L, 512]
# 第二层 MHA
h2 = self.layer2(h1) # [1, L, 512]
# 输出层:映射回词表维度,计算每个位置下一个词的概率 Logits
logits = self.lm_head(h2) # [1, L, vocab_size]
return logits
虽然定义是这么定义的,但是要预测下一个词,其实logits只需要用到最后一个L的数据,在这里也就是第7个L,来进行分类任务就行了。
那么我们整体的预测下一个词的流程是什么呢?
model = SimpleLLMNoCache(hidden_dim=512, num_heads=8, vocab_size=10000)
input_prompt = torch.randn(1, 7, 512)
logits = model(input_prompt) # [1, 7, 10000]
#预测下一个词:只需要看最后一个token位置上的 logits
next_token_logits = logits[:, -1, :] # [1, 10000]
# 假设从 10000 个词里取 argmax 出来的 token 就是 “当”
next_token = torch.argmax(next_token_logits, dim=-1)
此时如果我们需要预测然呢?很简单,你已经生成当了,你可以取到当的embedding,维度也是[1, 1, 512],将之与之前的embedding拼接,就能获得新的输入
new_input = torch.randn(1, 8, 512) # 包含 "中国人能飞吗?当"
# 再次前向传播:整条8个token的序列重算一次
logits = model(new_input) # [1, 8, 10000]
# 取第 8 个位置上的向量预测第 9 个字 "然"
next_token_logits = logits[:, -1, :] # [1, 10000]
好,此时我们关注一件事情,对于同一个初始输入,两个MHA的前几层的计算中间结果有变化吗?
import torch
import torch.nn as nn
import torch.nn.functional as F
# 1. 修正后的 MHA 模块(支持返回中间 K, V 方便观察)
class MultiHeadAttentionNoCache(nn.Module):
def __init__(self, hidden_dim=512, num_heads=8):
super().__init__()
self.hidden_dim = hidden_dim
self.num_heads = num_heads
self.head_dim = hidden_dim // num_heads
self.W_q = nn.Linear(hidden_dim, hidden_dim, bias=False)
self.W_k = nn.Linear(hidden_dim, hidden_dim, bias=False)
self.W_v = nn.Linear(hidden_dim, hidden_dim, bias=False)
self.W_o = nn.Linear(hidden_dim, hidden_dim, bias=False)
def forward(self, x):
B, L, D = x.shape
q = self.W_q(x).view(B, L, self.num_heads, self.head_dim).transpose(1, 2)
k = self.W_k(x).view(B, L, self.num_heads, self.head_dim).transpose(1, 2)
v = self.W_v(x).view(B, L, self.num_heads, self.head_dim).transpose(1, 2)
scores = torch.matmul(q, k.transpose(-2, -1)) / torch.sqrt(torch.tensor(self.head_dim, dtype=x.dtype))
# 构造因果矩阵并加上
mask_causal = torch.triu(torch.full((L, L), float('-inf'), device=x.device), diagonal=1)
scores = scores + mask_causal # 修正原代码拼写错误
attn_weights = F.softmax(scores, dim=-1)
out = torch.matmul(attn_weights, v)
out = out.transpose(1, 2).contiguous().view(B, L, D)
out = self.W_o(out)
# 额外返回 k 和 v,方便观察中间变量
return out, k, v
class SimpleLLMNoCache(nn.Module):
def __init__(self, hidden_dim=512, num_heads=8, vocab_size=10000):
super().__init__()
self.layer1 = MultiHeadAttentionNoCache(hidden_dim, num_heads)
self.layer2 = MultiHeadAttentionNoCache(hidden_dim, num_heads)
self.lm_head = nn.Linear(hidden_dim, vocab_size, bias=False)
def forward(self, x):
h1, k1, v1 = self.layer1(x)
h2, k2, v2 = self.layer2(h1)
logits = self.lm_head(h2)
# 返回最终 logits 及中间过程变量
return logits, {
'layer1_k': k1, 'layer1_v': v1, 'h1': h1,
'layer2_k': k2, 'layer2_v': v2, 'h2': h2
}
# ==================== 验证实验:两次前向传播对比 ====================
# 0. 固定随机种子,初始化模型
torch.manual_seed(42)
model = SimpleLLMNoCache(hidden_dim=512, num_heads=8, vocab_size=10000).eval()
# 模拟前 7 个 Token("中国人能飞吗?")的 Embedding 矩阵
prompt_7_tokens = torch.randn(1, 7, 512)
# 模拟刚预测出来的第 8 个 Token("当")的 Embedding
token_8_embedding = torch.randn(1, 1, 512)
# 拼接成 8 个 Token("中国人能飞吗?当")
prompt_8_tokens = torch.cat([prompt_7_tokens, token_8_embedding], dim=1)
# ------------------------------------------------------------------
# 第一次执行:输入 7 个 Token
# ------------------------------------------------------------------
with torch.no_grad():
logits_pass1, inter_pass1 = model(prompt_7_tokens)
# ------------------------------------------------------------------
# 第二次执行:输入 8 个 Token
# ------------------------------------------------------------------
with torch.no_grad():
logits_pass2, inter_pass2 = model(prompt_8_tokens)
# ------------------------------------------------------------------
# 打印对比结果(截取第二次执行中前 7 个 Token 的中间输出进行对比)
# ------------------------------------------------------------------
print("=" * 65)
print("对比两次执行中【前 7 个 Token】对应位置的中间计算结果:")
print("=" * 65)
def compare_tensors(name, tensor1, tensor2_slice):
# 计算最大绝对误差
max_diff = torch.max(torch.abs(tensor1 - tensor2_slice)).item()
is_equal = torch.allclose(tensor1, tensor2_slice, atol=1e-7)
print(f"[{name}]")
print(f" └─ Shape 对比: Pass1 {list(tensor1.shape)} vs Pass2[:7] {list(tensor2_slice.shape)}")
print(f" └─ 最大绝对误差 (Max Diff): {max_diff:.8e}")
print(f" └─ 数值完全一致: {is_equal}\n")
# Layer 1 的 K 矩阵 (仅对比前 7 个位置)
compare_tensors("Layer 1 - K 矩阵", inter_pass1['layer1_k'], inter_pass2['layer1_k'][:, :, :7, :])
# Layer 1 的 V 矩阵 (仅对比前 7 个位置)
compare_tensors("Layer 1 - V 矩阵", inter_pass1['layer1_v'], inter_pass2['layer1_v'][:, :, :7, :])
# Layer 1 的输出状态 h1 (仅对比前 7 个位置)
compare_tensors("Layer 1 - 输出隐藏状态 h1", inter_pass1['h1'], inter_pass2['h1'][:, :7, :])
# Layer 2 的输出状态 h2 (仅对比前 7 个位置)
compare_tensors("Layer 2 - 输出隐藏状态 h2", inter_pass1['h2'], inter_pass2['h2'][:, :7, :])
# 最终 Logits (仅对比前 7 个位置)
compare_tensors("LM Head - 最终 Logits", logits_pass1, logits_pass2[:, :7, :])
最终执行的结果是
=================================================================
对比两次执行中【前 7 个 Token】对应位置的中间计算结果:
=================================================================
[Layer 1 - K 矩阵]
└─ Shape 对比: Pass1 [1, 8, 7, 64] vs Pass2[:7] [1, 8, 7, 64]
└─ 最大绝对误差 (Max Diff): 0.00000000e+00
└─ 数值完全一致: True
[Layer 1 - V 矩阵]
└─ Shape 对比: Pass1 [1, 8, 7, 64] vs Pass2[:7] [1, 8, 7, 64]
└─ 最大绝对误差 (Max Diff): 0.00000000e+00
└─ 数值完全一致: True
[Layer 1 - 输出隐藏状态 h1]
└─ Shape 对比: Pass1 [1, 7, 512] vs Pass2[:7] [1, 7, 512]
└─ 最大绝对误差 (Max Diff): 2.38418579e-07
└─ 数值完全一致: True
[Layer 2 - 输出隐藏状态 h2]
└─ Shape 对比: Pass1 [1, 7, 512] vs Pass2[:7] [1, 7, 512]
└─ 最大绝对误差 (Max Diff): 1.49011612e-07
└─ 数值完全一致: True
[LM Head - 最终 Logits]
└─ Shape 对比: Pass1 [1, 7, 10000] vs Pass2[:7] [1, 7, 10000]
└─ 最大绝对误差 (Max Diff): 1.26659870e-07
└─ 数值完全一致: True
你会发现,KV的结果是完全一致,如果不考虑浮点计算精度导致的问题,logic的结果也是完全一致的,这是为何呢!
因为因果掩码的存在,第二次计算中前7个token的计算output完全不受到第8个token的影响,也就是说,对于第二次的计算MHA的时候,前七个token的embedding是不会变的。这里我让ai画了个图,我觉得是很好理解的。
首先是为什么KV能够缓存:
第一次前向传播 (Pass 1 - 输入 7 个 Token):
[ X_1 ] [ K_1 ] [ V_1 ]
[ X_2 ] [ K_2 ] [ V_2 ]
[ ... ] * W_k, W_v = [ ... ] 和 [ ... ]
[ X_7 ] [ K_7 ] [ V_7 ]
---------------------------------------------------------------------
第二次前向传播 (Pass 2 - 输入 8 个 Token):
[ X_1 ] [ K_1 ] (完全一致) [ V_1 ] (完全一致)
[ X_2 ] [ K_2 ] [ V_2 ]
[ ... ] * W_k, W_v = [ ... ] 和 [ ... ]
[ X_7 ] [ K_7 ] [ V_7 ]
=====================================================================
[ X_8 ] <-- 新增 [ K_8 ] <-- 新增 [ V_8 ] <-- 新增
其次是为什么这个MHA的输出两次也是相同的,也就是进行了Attention以及过了后面的W_o依然相同
这两次计算,既然K,V是相同的,唯一可能影响结果的只有Query了,但是由于因果掩码的存在
Key 1 Key 2 ... Key 7 | Key 8 (新增)
+-------------------------------+----------------
Query 1 | S_11 -inf ... -inf | -inf <-- 被 Mask 挡住!
Query 2 | S_21 S_22 ... -inf | -inf <-- 被 Mask 挡住!
... | ... ... ... ... | -inf
Query 7 | S_71 S_72 ... S_77 | -inf <-- 被 Mask 挡住!
-------------+-------------------------------+----------------
Query 8(新增)| S_81 S_82 ... S_87 | S_88
我们可以发现 (Q·K_T)·V之前,进行了掩码操作,Q_8以及K_8的结果,无法影响到计算O_0到O_7的最终计算过程,所以此时,说明,对于下一个MHA,输入的前7个编码是一定一致的,既然输入一致,那么下个MHA的中间结果也是一直的,也就是说不仅是对于这一个MHA可以缓存KV,后面的任何MHA的KV都是可以缓存的!
至此,我们已经成功论证了KV Cache的有效性!
放飞思想,学习理当从愉快的阅读开始