vLLM 推理内核深度解析
第7章 模型加载与权重管理:从磁盘到 GPU 的 140 GB 旅程
第7章 模型加载与权重管理:从磁盘到 GPU 的 140 GB 旅程
“Moving data is the bottleneck, not computing on it.” — Jeff Dean
本章要点
- 用 Llama-3-70B 真实数据量化:140 GB 权重 × 30+ 分片文件 × PCIe 25 GB/s × TP 切分 × 量化变体 = 一个纯粹的”数据管道工程”
- 掌握
BaseModelLoader策略模式下的 7 种加载器:各自适配什么场景、什么时候该用哪个 - 读懂 safetensors 为什么是默认优先路径:按 shard、按 tensor 迭代,避免把完整 checkpoint 一次性反序列化进内存
- 理解
_prepare_weights的四步流水线:远程解析 → 文件下载 → 分片去重 → 格式确认 - 看懂
stacked_params_mapping的妙用:HuggingFace 的q_proj/k_proj/v_proj如何被 vLLM 合并成单一qkv_proj - 掌握 Megatron 式权重切分:Column vs Row 在 load 时就完成,不是 load 完再切
- 理解量化权重加载的三重差异:bit packing、scale/zero_point 处理、kernel 匹配
- 学会 Tensorizer / Run:ai streamer / ShardedState 三种加速首次加载的手段
- 知道如何为 vLLM 添加一个新模型:三个方法接口 + 一行注册表
- 拿到四个场景的加载优化配方:单机快速启动 / 多副本冷启动 / K8s 滚动重启 / 极速开发迭代
7.1 问题规模:为什么”加载模型”是一个工程问题
打开 HuggingFace 上的 meta-llama/Llama-3-70B-Instruct,你会看到:
config.json 1 KB
tokenizer.json 9 MB
model-00001-of-00030.safetensors 4.7 GB
model-00002-of-00030.safetensors 4.7 GB
...
model-00030-of-00030.safetensors 4.7 GB
model.safetensors.index.json 28 KB
30 个分片文件,每个约 4.7 GB,总计 140 GB。这就是一次启动要搬运的原始数据量。
对比 Web 服务的”启动”:Web 服务启动 = 几秒钟(拉几 MB 代码到内存);LLM 服务启动 = 几分钟(搬几十上百 GB 权重到 GPU)。启动是一类完全不同的工程问题。
7.1.1 搬运路径的三级带宽
权重从磁盘走到 GPU 显存要穿越三级带宽:
graph LR
Disk["💾 磁盘<br/>HDD 0.2 GB/s<br/>SATA SSD 0.5 GB/s<br/>NVMe SSD 3-7 GB/s"]
Disk -->|"磁盘 I/O"| CPU["🧠 CPU 内存<br/>DDR5 ~60 GB/s"]
CPU -->|"PCIe"| GPU["🔥 GPU HBM<br/>A100 2 TB/s<br/>H100 3.35 TB/s"]
Note[带宽瓶颈在最慢的一环:<br/>NVMe → PCIe → GPU 的链路<br/>实际速度由 NVMe 和 PCIe 决定]
Disk -.-> Note
style Disk fill:#94a3b8,color:#fff,stroke:none
style CPU fill:#3b82f6,color:#fff,stroke:none
style GPU fill:#10b981,color:#fff,stroke:none
实际数字:
- 磁盘 → CPU(NVMe SSD 顺序读):3-7 GB/s
- CPU → GPU(PCIe 4.0 x16 单向):32 GB/s 理论,实际 ~25 GB/s
- GPU → GPU(NVLink 5 双向):900 GB/s
- 机间(InfiniBand NDR):50 GB/s
Llama-70B 140 GB 在理论峰值下:
- 磁盘读:140 / 5 = 28 秒
- PCIe 传输:140 / 25 = 5.6 秒
- 总理论下限:34 秒
实际:首次加载 Llama-70B 通常要 2-5 分钟。中间的时间去哪了?这就是本章要解释的东西——加载不是一次顺序传输,中间有解析、反序列化、切分、合并、量化重排、weight mapping 等一系列工序。
7.1.2 朴素方法会怎么崩
如果用最直观的方式:
# 朴素实现 —— 会崩
import torch
model = torch.load("model-00001-of-00030.safetensors") # OOM!
for shard in shards:
state = torch.load(shard) # 每次都加载到 CPU
model.load_state_dict(state, strict=False)
model = model.to("cuda") # 70B 一张 80GB 卡放不下
崩点:
torch.load把整个 shard 读进 CPU 内存——30 个 shard 连续加载后 CPU 内存占用可能达 280 GB(PyTorch load 通常会有一份副本)- 单卡放不下——Llama-70B FP16 = 140 GB > 任何单卡
- weight name 不对齐——HuggingFace 权重名(如
model.layers.0.self_attn.q_proj.weight)和 vLLM 内部参数名不同 - TP 切分问题——多卡场景下每张卡只需要一份切片,不是完整权重
vLLM 的模型加载子系统就是为了解决这四个问题。
7.2 BaseModelLoader 策略模式:7 种加载器
vLLM 的加载逻辑组织成一个策略模式,所有具体加载器都继承 BaseModelLoader:
# vllm/model_executor/model_loader/loader.py
class BaseModelLoader(ABC):
def __init__(self, load_config: LoadConfig):
self.load_config = load_config
@abstractmethod
def download_model(self, model_config: ModelConfig) -> None:
"""下载模型(如果是远程 HF / S3)。"""
@abstractmethod
def load_model(self, *, vllm_config: VllmConfig) -> nn.Module:
"""把模型加载到 GPU 并返回 nn.Module。"""
七个具体实现:
graph TB
Base[BaseModelLoader]
Base --> DM["DefaultModelLoader<br/>标准 safetensors/PT 加载"]
Base --> Dum["DummyModelLoader<br/>随机权重 (benchmark 用)"]
Base --> Ten["TensorizerLoader<br/>CoreWeave 序列化格式"]
Base --> Shard["ShardedStateLoader<br/>预分片 state_dict"]
Base --> BnB["BitsAndBytesModelLoader<br/>FP16 → NF4/FP4 实时量化"]
Base --> GGUF["GGUFModelLoader<br/>llama.cpp 兼容"]
Base --> Runai["RunaiModelStreamerLoader<br/>Run:ai S3 并行流式加载"]
style DM fill:#10b981,color:#fff,stroke:none
style Shard fill:#3b82f6,color:#fff,stroke:none
style Ten fill:#3b82f6,color:#fff,stroke:none
style Runai fill:#3b82f6,color:#fff,stroke:none
style BnB fill:#f59e0b,color:#fff,stroke:none
style GGUF fill:#f59e0b,color:#fff,stroke:none
style Dum fill:#94a3b8,color:#fff,stroke:none
适用场景速查表:
| Loader | 适用 | 加载速度 | 典型场景 |
|---|---|---|---|
| DefaultModelLoader | 标准 HF 模型 | 标准 | 99% 场景,生产默认 |
| TensorizerLoader | 预先 tensorize 过的模型 | 取决于序列化介质和网络 | 经常重启的生产环境 |
| ShardedStateLoader | 预先 TP-shard 过的 state_dict | 避免每次重切 TP shard | K8s 滚动部署 |
| RunaiModelStreamerLoader | S3 云存储 | 并行下载 | 无本地 SSD 的环境 |
| BitsAndBytesModelLoader | FP16 checkpoint + 运行时量化 | 慢(量化耗时) | 想快速试量化效果 |
| GGUFModelLoader | GGUF 格式(llama.cpp) | 标准 | 复用 llama.cpp 生态 |
| DummyModelLoader | 不加载真实权重 | 秒级 | Benchmark / debug |
添加新加载器只需实现两个方法——典型的可插拔设计。
这里要把两个数字分清。LoadFormat 在 vllm/config.py:1475-1488 里列了 13 个枚举值,包括 auto、safetensors、fastsafetensors、mistral、npcache 这类格式选择,也包括 tensorizer、sharded_state、bitsandbytes、gguf、runai_streamer 这类专门路径。真正落到 get_model_loader() 时,loader.py:1516-1542 会把这些配置路由到 7 个 concrete loader;没有命中特殊路径的配置统一回到 DefaultModelLoader。所以工程上不是”一个格式一个类”,而是”少数加载策略覆盖多个格式分支”。
这个设计的好处是职责边界很清楚:LoadConfig 负责表达用户意图,get_model_loader() 负责把意图映射到策略类,具体 loader 再决定是否下载、如何迭代权重、何时执行后处理。新增一个特殊存储格式时,如果它仍然能产出 (name, tensor) 迭代器,就应该尽量挂在 DefaultModelLoader 的分支里;只有当它改变了模型初始化、权重来源或 TP shard 的语义,才值得新增 loader。
7.3 DefaultModelLoader:生产主路径深度拆解
99% 用户用的都是 DefaultModelLoader。它的加载流程分四步:
flowchart TB
Start[load_model 开始]
Step1["1. _prepare_weights<br/>远程解析 → 下载 → 分片去重 → 格式选定"]
Step2["2. _get_weights_iterator<br/>lazy 返回 (name, tensor) 迭代器"]
Step3["3. model.load_weights()<br/>weight name 映射 + QKV/gate_up 合并 + TP 切分"]
Step4["4. _process_weights_after_loading<br/>量化/注意力层后处理"]
Ready[模型就绪]
Start --> Step1 --> Step2 --> Step3 --> Step4 --> Ready
style Step1 fill:#3b82f6,color:#fff,stroke:none
style Step2 fill:#8b5cf6,color:#fff,stroke:none
style Step3 fill:#ec4899,color:#fff,stroke:none
style Step4 fill:#10b981,color:#fff,stroke:none
7.3.1 _prepare_weights:定位 + 下载 + 格式确认
# vllm/model_executor/model_loader/loader.py(概念性简化)
def _prepare_weights(self, model_name_or_path: str, ...) -> tuple[str, list[str], bool]:
# (1) 判断是不是远程 HF 模型 ID
is_local = os.path.isdir(model_name_or_path)
if not is_local:
# (2) 下载到 HF cache 目录
hf_folder = snapshot_download(
model_name_or_path,
allow_patterns=["*.safetensors", "*.bin", "*.json"],
cache_dir=self.load_config.download_dir,
)
else:
hf_folder = model_name_or_path
# (3) 识别格式(safetensors 优先)
if self._has_safetensors(hf_folder):
allow_patterns = ["*.safetensors"]
use_safetensors = True
else:
allow_patterns = ["*.bin"]
use_safetensors = False
# (4) 收集匹配的 shard 文件
hf_weights_files = [
os.path.join(hf_folder, f) for f in os.listdir(hf_folder)
if any(fnmatch.fnmatch(f, p) for p in allow_patterns)
]
# (5) 去重(同一模型可能同时提供 merged 和 sharded)
if use_safetensors:
hf_weights_files = filter_duplicate_safetensors_files(
hf_weights_files, hf_folder,
)
return hf_folder, hf_weights_files, use_safetensors
几个关键细节:
细节 1:safetensors 优先,但不是硬编码盲选。在 auto 模式下,loader.py:288-289 同时允许 *.safetensors 和 *.bin;真正下载时,download_weights_from_hf() 会先向 Hub 列文件,再按 allow_patterns 的顺序选择第一个有匹配结果的格式(weight_utils.py:252-263)。本地目录也走同样的 pattern 顺序扫描(loader.py:322-328)。这比简单检查目录里有没有某个扩展名稳,因为远程仓库、本地缓存和离线模式都能复用同一套规则。
细节 2:去重逻辑。一些模型仓库既提供 consolidated safetensors,又提供 sharded safetensors;两套文件同时交给 loader 会重复加载同名权重。filter_duplicate_safetensors_files() 会读 model.safetensors.index.json 的 weight_map,只保留索引里引用的文件(weight_utils.py:325-346)。如果索引文件不存在,它不会猜测文件关系,而是原样返回匹配结果。
细节 3:allow_patterns 是下载边界,不是清理脚本。远程下载只拉当前格式需要的文件;本地加载只扫描匹配 pattern 的文件。ignore_patterns 默认会排除 original/**/*(config.py:1557-1563),避免某些 Llama 仓库里的原始 checkpoint 被重复纳入。对非 safetensors 路径,filter_files_not_needed_for_inference() 还会排除 optimizer.pt、scheduler.pt、training_args.bin 这类训练产物(weight_utils.py:349-367)。
细节 4:失败要早,不能半加载。如果扫描完没有任何权重文件,_prepare_weights() 直接抛 RuntimeError(loader.py:349-351)。加载完成后,DefaultModelLoader.load_model() 会收集模型参数名,调用 model.load_weights(),并在非量化模型上检查有没有参数没从 checkpoint 初始化(loader.py:454-469)。这一步很重要:模型加载最危险的错误不是进程崩掉,而是某个参数静默保持随机初始化,然后服务还能启动。
7.3.2 safetensors 为什么是默认优先格式
一个 70B 模型如果用朴素 torch.load 一次性读完整 checkpoint,CPU 内存峰值会非常高;如果按 safetensors shard 迭代,每次只把当前 tensor 暴露给上层加载逻辑,内存曲线会更接近”当前 tensor + page cache”。这里不需要夸大成固定倍数,源码里真正可验证的是两条路径的形态差异。
torch.load 的行为:
1. open file
2. read all bytes into CPU memory ← 140 GB 全塞进内存
3. unpickle ← 反序列化再造一份 tensor
4. return state_dict
CPU 内存通常会承受完整 state dict 的压力,而且 pickle 反序列化无法像 safetensors 一样先读 header 再按 tensor 拉取。对几十到上百 GB 的模型,这种路径很容易把启动瓶颈从 GPU 显存转移到主机内存和 page cache 抖动上。
safetensors mmap 的行为:
1. open file
2. mmap header(~几 KB,描述所有 tensor 的 offset + shape + dtype)
3. 对每个 tensor:
torch.frombuffer(mmap_slice) ← 零拷贝,CPU 内存不增加
tensor.to("cuda") ← 直接从 mmap 搬到 GPU
safetensors_weights_iterator() 的真实代码也非常短:遍历 shard 文件,safe_open(st_file, framework="pt"),再对 f.keys() 逐个 get_tensor() 并 yield(weight_utils.py:430-444)。这说明 vLLM 没有先构造一个巨大的 state_dict;它把 checkpoint 变成流,后面的 load_weights() 边读边映射、边切分、边拷贝。
因此,safetensors 是默认优先路径的原因不是一个单点性能神话,而是三个工程属性同时成立:格式有 header,适合按 tensor 定位;文件不可执行,避免 pickle 反序列化的代码执行风险;迭代器天然能和 TP 切分、QKV 合并、量化参数注册衔接。
7.3.3 权重迭代器:lazy 读取
_get_weights_iterator 返回一个 Python generator,每次 yield (name, tensor) 对:
def safetensors_weights_iterator(hf_weights_files):
for shard_file in hf_weights_files:
with safe_open(shard_file, framework="pt", device="cpu") as f:
for name in f.keys():
yield name, f.get_tensor(name) # mmap 按需读
上层用:
for name, weight in weights_iterator:
# 处理这一个 tensor
model_param = model.state_dict()[mapped_name]
load_into(model_param, weight)
# tensor 出作用域自动 GC
内存占用峰值就是”最大的单个 tensor”的大小——几 MB 到几十 MB 级别,而不是整个 shard。
7.3.4 加载流程的状态机视角
把 DefaultModelLoader 展开成状态机,会比”下载然后 load”更准确:
| 状态 | 进入条件 | 退出条件 | 失败边界 |
|---|---|---|---|
| 解析来源 | model_config.model 是本地路径、HF ID 或 ModelScope ID | 得到本地 hf_folder | 远程不可达、revision 不存在 |
| 选择格式 | LoadFormat.AUTO 或用户显式指定格式 | 得到 allow_patterns 与 use_safetensors | 指定了 vLLM 不认识的格式 |
| 下载/扫描 | 远程走 snapshot_download,本地走 glob | 得到候选文件列表 | 没有任何匹配权重 |
| 去重/过滤 | safetensors 读 index,bin/pt 过滤训练产物 | 得到最终文件列表 | index 指向文件缺失 |
| 构造迭代器 | 根据格式选 safetensors、fastsafetensors、pt、npcache | 产生 (name, tensor) 流 | 单个 shard 损坏或 tensor shape 不匹配 |
| 模型接收 | model.load_weights() 消费迭代器 | 返回已加载参数集合 | 参数名映射失败、shape 检查失败、非量化模型参数缺失 |
这个状态机解释了为什么模型加载代码里有大量小的 guard:加载不是一个原子动作,而是一串跨网络、文件系统、格式解析、模型结构和分布式切分的边界。如果任何一步选择”容错继续”,后面的错误会变得更隐蔽;vLLM 在关键位置选择 fail fast,是生产推理系统更可靠的做法。
这也是排障时的阅读顺序:先确认格式选择,再确认文件列表,最后才看模型类映射。顺序反了,容易把文件缺失误判成模型实现问题。
7.4 load_weights:模型类的名称映射 + 权重合并魔法
HuggingFace 模型的参数名和 vLLM 内部的参数名不一致。为什么?因为 vLLM 做了一系列工程优化——最典型的是权重合并。
7.4.1 HuggingFace 的 Llama:三个独立投影
# HuggingFace Transformers 的 Llama 模型里
class LlamaAttention:
self.q_proj = nn.Linear(hidden, hidden, bias=False) # Q 投影
self.k_proj = nn.Linear(hidden, hidden, bias=False) # K 投影
self.v_proj = nn.Linear(hidden, hidden, bias=False) # V 投影
权重名:
model.layers.0.self_attn.q_proj.weight [hidden, hidden]
model.layers.0.self_attn.k_proj.weight [hidden, hidden]
model.layers.0.self_attn.v_proj.weight [hidden, hidden]
forward 时:
q = self.q_proj(x) # kernel launch 1
k = self.k_proj(x) # kernel launch 2
v = self.v_proj(x) # kernel launch 3
三次 matmul kernel launch。在 decode 阶段每 step 要跑,吞吐瓶颈之一。
7.4.2 vLLM 的合并:一个大投影
# vLLM 内部
class LlamaAttention:
self.qkv_proj = QKVParallelLinear(
hidden, num_heads, num_kv_heads, bias=False,
)
# 权重 shape: [hidden, q_size + 2 * kv_size]
权重名:
model.layers.0.self_attn.qkv_proj.weight [q+k+v 拼接的大矩阵]
forward 时:
qkv = self.qkv_proj(x) # 一次 kernel launch
q, k, v = qkv.split([q_size, kv_size, kv_size], dim=-1)
合并成一次 matmul,减少投影层数量,也让 TP 切分更集中:一次列并行布局就能覆盖 Q/K/V 的目标位置。具体收益会随 batch、head 数、硬件和 kernel 实现变化,章节里不需要给一个脱离实验环境的固定微秒数;真正稳定的事实是 vLLM 的内部参数布局与 HF checkpoint 布局不同,加载阶段必须完成这个映射。
7.4.3 stacked_params_mapping:weight name 自动转换
但 HuggingFace 的权重文件里存的是 q_proj / k_proj / v_proj 三份。怎么加载到 qkv_proj 里?
# vllm/model_executor/models/llama.py
class LlamaForCausalLM(nn.Module):
def load_weights(self, weights: Iterable[tuple[str, Tensor]]):
# 映射规则:(vllm 内部名, HF 原始名, shard_id)
stacked_params_mapping = [
("qkv_proj", "q_proj", "q"),
("qkv_proj", "k_proj", "k"),
("qkv_proj", "v_proj", "v"),
("gate_up_proj", "gate_proj", 0),
("gate_up_proj", "up_proj", 1),
]
for hf_name, hf_weight in weights:
for vllm_target, hf_prefix, shard_id in stacked_params_mapping:
if hf_prefix in hf_name:
# 比如 hf_name = "model.layers.0.self_attn.q_proj.weight"
# 替换为: "model.layers.0.self_attn.qkv_proj.weight"
vllm_name = hf_name.replace(hf_prefix, vllm_target)
param = self.state_dict()[vllm_name]
# weight_loader 知道怎么把 Q/K/V 填到 qkv_proj 的对应切片里
param.weight_loader(param, hf_weight, shard_id)
break
else:
# 非合并权重,直接加载
vllm_name = hf_name
param = self.state_dict()[vllm_name]
weight_loader = getattr(param, "weight_loader", default_weight_loader)
weight_loader(param, hf_weight)
weight_loader 是每个 parameter 带的一个小函数(通常在 __init__ 时由 QKVParallelLinear 等层注入),它知道:
- 目标权重的 shard 布局(Q 占哪几行、K 占哪几行、V 占哪几行)
- 当前 rank 在 TP 中的位置(只拷对应切片)
- 量化权重的 pack 方式(如果有)
所有这些逻辑对上层调用者透明。load_weights 的代码只负责 name → name 的字符串映射。
7.4.4 MLP 的 gate_up_proj 合并
类似的合并也发生在 MLP:
HuggingFace: gate_proj + up_proj + down_proj(3 个 Linear)
vLLM: gate_up_proj + down_proj(2 个 Linear,合并 gate+up)
forward:
gate_up = gate_up_proj(x) # 一次 matmul
gate, up = gate_up.chunk(2, dim=-1)
return down_proj(silu(gate) * up)
省一次 kernel launch。对 decode 场景累积收益可观。
7.5 Megatron 式权重切分:在 load 时就完成
多卡 TP 场景下,每张卡只需要完整权重的 1/tp_size。朴素做法是”先全量 load 再切分”——但这意味着每张卡临时需要完整 140 GB 的 CPU 内存,不可接受。
vLLM 的做法:权重在进入 GPU 之前就按 TP rank 切好。
7.5.1 列并行权重的加载
# QKVParallelLinear 的 weight_loader(概念性简化)
def qkv_weight_loader(param, loaded_weight, shard_id):
tp_rank = get_tensor_model_parallel_rank()
tp_size = get_tensor_model_parallel_world_size()
# 按 shard_id 决定目标在 qkv_proj 里的偏移
if shard_id == "q":
target_offset = 0
target_size = num_heads_per_rank * head_dim
elif shard_id == "k":
target_offset = q_size
target_size = num_kv_heads_per_rank * head_dim
else: # v
target_offset = q_size + kv_size
target_size = num_kv_heads_per_rank * head_dim
# TP 切分:每张卡只拿对应的 heads
source_size = loaded_weight.shape[0]
shard_offset = tp_rank * (source_size // tp_size)
shard_end = shard_offset + (source_size // tp_size)
loaded_weight_sharded = loaded_weight[shard_offset:shard_end]
# 拷到目标位置
param[target_offset:target_offset + target_size].copy_(loaded_weight_sharded)
关键点:
loaded_weight[shard_offset:shard_end]—— 这是 numpy 切片,对 mmap 来说零拷贝- 目标参数
param已经是切好尺寸的([hidden, (q_size+2kv_size)/tp_size]) - 整个过程从磁盘到 GPU 只走一份切片的数据量,不是完整权重
对 TP=8 的 70B 模型,理想情况下每张 rank 最终只保留约 140/8 = 17.5 GB 的权重。实际读盘量是否也接近这个数,取决于格式迭代器和 weight loader 能否在 materialize 之前完成切片;PySafeSlice 支持索引但不支持复杂变换,weight_utils.py:560-566 专门提醒:如果后续需要 .view()、.t() 这类操作,就必须先转成 tensor。也就是说,“load 时切分”是目标路径,不是所有量化/特殊层都能无条件零拷贝。
7.5.2 行并行权重的加载
类似地,RowParallelLinear 按输入维度切分:
def row_parallel_weight_loader(param, loaded_weight):
tp_rank = get_tensor_model_parallel_rank()
tp_size = get_tensor_model_parallel_world_size()
# 按行切(按输入维度)
input_size = loaded_weight.shape[1]
shard_size = input_size // tp_size
start = tp_rank * shard_size
end = start + shard_size
loaded_weight_sharded = loaded_weight[:, start:end]
param.copy_(loaded_weight_sharded)
Column + Row 的切分方式跟第 14 章讲过的 Megatron TP 数学完全对应。
7.6 量化模型的特殊加载路径
量化权重和 FP16 权重存储方式完全不同。以 GPTQ 4-bit 为例:
7.6.1 Bit packing 的处理
GPTQ 把 4-bit 权重 pack 进 int32——8 个 4-bit 值塞进一个 int32 里:
INT32 存储: [b31..b28|b27..b24|...|b3..b0]
对应 8 个 4-bit 权重
读的时候需要解包或重排。vLLM 的量化路径通常会让 checkpoint 里的 packed 权重以量化参数的形式注册到 layer 上,再由后处理或 kernel 消费;这比”先在 CPU 还原成 FP16,再传 GPU”更符合推理场景。
加载时权重直接按 packed 格式搬到 GPU:
# vllm/model_executor/layers/quantization/gptq_marlin.py(概念性)
def apply(self, layer, x):
# layer.weight 是 packed int32 格式
# Marlin kernel 接受 packed 权重 + scale + zero_point
return gptq_marlin_gemm(
x,
layer.weight, # int32 packed
layer.weight_scale, # FP16
layer.weight_zp, # int32 packed
workspace=...,
)
7.6.2 scale / zero_point 的加载
量化权重不是单一张量——还有配套的 scale 和 zero_point。load_weights 时要把三个张量都处理好:
model.layers.0.self_attn.q_proj.qweight # int32 packed
model.layers.0.self_attn.q_proj.qzeros # int32 packed
model.layers.0.self_attn.q_proj.scales # FP16
vLLM 的 GPTQMarlinLinearMethod.create_weights 会预先注册这三种 parameter,加载时按 name 自动 route。
7.6.3 kernel 自动选择
不同量化格式需要不同的 kernel。get_quant_method 根据硬件能力自动选择:
def get_quant_method(self, layer, prefix):
if is_ampere_or_newer() and self.bits == 4:
return GPTQMarlinLinearMethod(self) # Marlin 最快
elif is_volta_or_newer():
return GPTQLinearMethod(self) # ExLlama fallback
else:
raise NotImplementedError
加载过程对量化的三个处理(pack、scale/zero_point、kernel)完全封装在 QuantizationConfig 子类里,上层 load_weights 不需要知道。
7.6.4 量化加载不能只看 dtype
量化 checkpoint 容易被误解成”把 FP16 换成 int4/int8 文件”。从 loader 视角看,这个说法太粗。真正复杂的是:每种量化方法都会改变 layer 上注册的参数集合、参数 shape、后处理步骤和 kernel 入口。
以 AWQ Marlin 为例,create_weights() 会注册 qweight、qzeros、scales 三类参数(awq_marlin.py:185-248),MoE 路径还会注册 w13_qweight、w2_qweight、w13_scales、w2_scales 等专家维度参数(awq_marlin.py:324-394)。这些名字不是普通 FP16 模型里的 weight;所以 load_weights() 的名字映射必须能把 checkpoint tensor 路由到这些量化参数上。
再看后处理:AWQ Marlin 的 process_weights_after_loading() 会把 checkpoint 中的布局 repack 成 Marlin kernel 需要的布局,并替换 layer 上的参数对象(awq_marlin.py:258-293)。这就是为什么 §7.8 的 device_loading_context() 不能被解释成普通加载阶段的显存优化;它真正服务的是这种”参数已经加载完,但还要按 kernel 需求变形”的阶段。
| 维度 | FP16 普通权重 | 量化权重 |
|---|---|---|
| 参数名 | 通常是 weight / bias | 可能是 qweight、qzeros、scales 或专家参数 |
| shape 语义 | 直接对应矩阵维度 | 常包含 pack、group size、专家数或 scale 粒度 |
| 加载后处理 | 多数是直接可用 | 可能要 repack、permute scales、替换 parameter |
| 失败形态 | shape mismatch 比较直观 | 可能在后处理或 kernel 调用阶段才暴露 |
因此,判断一个量化模型是否”支持 vLLM”不能只看它的文件能不能被下载,也不能只看 quantization 字段是否被识别。更可靠的检查顺序是:量化配置类是否存在;目标 Linear / MoE layer 是否注册了对应参数;模型类的 load_weights() 是否处理这些名字;process_weights_after_loading() 是否能在当前设备和并行拓扑下完成;最后才是 kernel 是否支持当前 GPU 架构。
7.7 三种加速首次加载的手段
大模型冷启动慢是生产部署的痛。vLLM 提供三类经常会被拿来优化首次加载的路径。它们的共同点不是承诺固定加速倍数,而是把”每次启动都做的工作”迁移到更便宜的位置:预序列化、预切分,或者并行读取。
7.7.1 Tensorizer:序列化加速
CoreWeave 的 Tensorizer 把模型序列化成一种连续字节流格式。加载时可以做:
- 并行流式读(多线程读多个字节段)
- 直接 S3 → GPU(skip CPU,如果是云存储)
- 压缩(可选 zstd)
TensorizerLoader 在 loader.py:503-590 中有两条路径:如果输入已经是 vLLM tensorized 模型,就直接 load_with_tensorizer();否则仍然要初始化模型,再通过 tensorizer 的权重迭代器调用 model.load_weights()。前者才是”把昂贵转换前置”的路径,后者只是换了读取介质。
# 先一次性序列化
serializer = TensorSerializer("llama-70b.tensors")
serializer.write_module(model)
serializer.close()
# 后续加载
vllm serve llama-70b.tensors --load-format tensorizer
7.7.2 ShardedStateLoader:预切分
对 TP 部署,每次启动都要重新”按 TP rank 切分全量权重”。如果能先做好切分、保存 sharded state dict,后续启动就省去切分时间:
# 一次性:先按 TP=4 切好保存
vllm save-sharded-state \
--model meta-llama/Llama-3-70B \
--tensor-parallel-size 4 \
--output-dir /fast-ssd/llama-70b-tp4/
# 后续启动:每张卡直接加载自己那份
vllm serve /fast-ssd/llama-70b-tp4/ \
--tensor-parallel-size 4 \
--load-format sharded_state
ShardedStateLoader 的源码注释说得很直白:它直接加载每个 worker 自己的 state dict,让大 TP 模型的每个 worker 只读自己的 shard,而不是完整 checkpoint(loader.py:603-609)。加载时会用当前 tensor parallel rank 拼出 model-rank-{rank}-part-{part}.safetensors 这样的文件名(loader.py:612、loader.py:698-710)。这条路径适合 TP 拓扑稳定的集群;如果 TP size、PP 划分或模型结构经常变,它的预处理产物反而容易变成运维负担。
7.7.3 Run:ai Model Streamer:S3 并行流式
对于模型存在 S3 的场景(K8s 部署常见),Run:ai 的 Model Streamer 提供多连接并行流式加载:
vllm serve s3://my-models/llama-70b/ \
--load-format runai_streamer
Run:ai 路径在源码里分两层:普通 runai_streamer 走 RunaiModelStreamerLoader,runai_streamer_sharded 则复用 ShardedStateLoader(load_config, runai_model_streamer=True)(loader.py:1536-1540)。权重迭代器最终落到 runai_safetensors_weights_iterator(),它用 SafetensorsStreamer 逐文件 stream,再 yield streamer 解析出的 tensors(weight_utils.py:447-460)。这条路径的价值在于远端对象存储的流式读取,不在于本地 NVMe 场景。
三种加速手段对比:
| 方案 | 优化点 | 前置成本 | 适用场景 |
|---|---|---|---|
| Tensorizer | 减少普通 checkpoint 解析和转换 | 一次性序列化 | 本地或对象存储上的重复冷启动 |
| ShardedState | 每个 rank 直接读取自己的 TP shard | 按固定并行拓扑保存 | 固定 TP 配置的 K8s |
| Run:ai Streamer | 对象存储流式读取 safetensors | 依赖 streamer 环境 | S3 / 云存储部署 |
生产实践:Tensorizer + 本地 NVMe 是单机部署的最快组合;ShardedState 在 K8s 上滚动重启时最稳;Run:ai Streamer 适合没本地 SSD 的云原生环境。
7.8 device_loading_context:后处理阶段的设备边界
这一节必须按源码重新理解。device_loading_context() 不是”加载前把 GPU 参数挪到 CPU,再最后搬回 GPU”。在 vLLM v0.8.5.post1 的 loader.py:68-108 里,它做的是相反方向的临时迁移:如果某个模块的参数原本在 CPU,而量化后处理需要目标设备,就把这些 CPU 参数临时搬到 target_device;后处理结束后,再复制回原来的 CPU 设备,必要时使用 pinned memory。
@contextmanager
def device_loading_context(module, target_device):
original_device_states = {}
for name, p in module.named_parameters():
if p.device.type == "cpu":
original_device_states[name] = p.device
p.data = p.data.to(target_device)
try:
yield module
finally:
for name, p in module.named_parameters():
if name in original_device_states:
p.data = copy_back_to_cpu_or_original_device(p.data)
它的调用点在 _process_weights_after_loading():遍历 model.named_modules(),如果模块的 quant_method 是 QuantizeMethodBase,就进入 device_loading_context(),调用 quant_method.process_weights_after_loading(module)(loader.py:164-180)。换句话说,它服务的是加载之后的量化重排、repack、scale 处理,而不是普通 FP16 safetensors 拷贝。
这个边界和 CPU offload 关系很大。部分部署会把一部分权重留在 CPU,或者先在 CPU 上保存某些量化参数;但 AWQ Marlin、FP8 等后处理往往需要调用 GPU kernel 或至少要求 tensor 位于目标设备。device_loading_context() 让后处理函数看到的是目标设备上的参数,同时不永久改变模块原来的放置策略。它保护的是”后处理语义和 offload 策略的兼容性”,不是一个通用的 2 倍显存节省开关。
这也解释了为什么 _process_weights_after_loading() 放在 model.load_weights() 后面。load_weights() 负责名字映射和数据填充;后处理负责把已经填好的量化权重改造成 kernel 想要的布局。例如某些 Marlin 路径会在 process_weights_after_loading() 中 repack qweight、permute scales、替换参数对象。把这两步拆开,loader 不需要知道每种量化 kernel 的内部格式,量化配置类也不需要接管下载和 checkpoint 格式选择。
7.9 模型注册表:146 个唯一架构如何被支持
本章基于本地 ../vllm 源码,版本为 v0.8.5.post1。registry.py 的分类条目不是简单相加后的最终支持数,因为 _EMBEDDING_MODELS 会通过 dict 展开复用一部分 Llama 家族条目,后续 _VLLM_MODELS 合并时相同 key 会覆盖。按源码计算,分类条目合计 163 个,合并后 _VLLM_MODELS 有 146 个唯一 architecture key,对应 125 个唯一实现类、114 个被引用的模型实现文件。
_TEXT_GENERATION_MODELS = {
# HF 架构名 → (模块名, 类名)
"LlamaForCausalLM": ("llama", "LlamaForCausalLM"),
"Qwen2ForCausalLM": ("qwen2", "Qwen2ForCausalLM"),
"MistralForCausalLM": ("llama", "LlamaForCausalLM"), # 复用 Llama
"Phi3ForCausalLM": ("phi3", "Phi3ForCausalLM"),
"Gemma2ForCausalLM": ("gemma2", "Gemma2ForCausalLM"),
"DeepseekV3ForCausalLM": ("deepseek_v3", "DeepseekV3ForCausalLM"),
"Qwen2VLForConditionalGeneration": ("qwen2_vl", "Qwen2VLForConditionalGeneration"),
# ...
}
加载时并不是直接拿 _TEXT_GENERATION_MODELS[hf_arch]。真实路径是:
architectures = model_config.hf_config.architectures
model_cls, arch = ModelRegistry.resolve_model_cls(architectures)
get_model_architecture() 从 config.json 的 architectures 字段开始(model_loader/utils.py:87-109)。如果用户显式要求 Transformers 后端,或者当前 architecture 没有 vLLM 原生实现但 Transformers backend 兼容,就先把 architecture 改写成 TransformersForCausalLM。最终再由 ModelRegistry.resolve_model_cls() 走 lazy import。
7.9.1 新增一个模型的三步
假设你想给 vLLM 加一个新模型 MyLLMForCausalLM:
Step 1 实现模型类(vllm/model_executor/models/my_llm.py):
class MyLLMForCausalLM(nn.Module):
def __init__(self, vllm_config: VllmConfig):
# 定义 Transformer 结构,复用 vllm.model_executor.layers 里的组件
...
def forward(
self,
input_ids: torch.Tensor,
positions: torch.Tensor,
inputs_embeds: Optional[torch.Tensor] = None,
) -> torch.Tensor:
# 前向传播
...
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]):
# weight name mapping + stacked_params_mapping
...
Step 2 在 registry 里加一行:
_TEXT_GENERATION_MODELS["MyLLMForCausalLM"] = ("my_llm", "MyLLMForCausalLM")
Step 3 提 PR 到 vllm-project/vllm,或者用户本地 patch。
更准确地说,新增一个模型至少需要三类接口:构造函数接受 vllm_config 和 prefix,forward 能对接 vLLM 的输入张量约定,load_weights() 能把 HF checkpoint 名字映射到内部参数布局。_initialize_model() 会优先检查新式构造函数签名(loader.py:128-140);旧式类还能被兼容,但会走 warning 路径。注册表只解决”找到类”,不解决”类是否符合执行层约定”。
7.9.2 对齐现有模型家族
很多模型架构相似(比如 Mistral 和 Llama 几乎一样),vLLM 的策略是直接复用:
"MistralForCausalLM": ("llama", "LlamaForCausalLM"),
Mistral、CodeLlama、Llama-2、Llama-3 等全系列都复用 LlamaForCausalLM。差异通过 config.json 里的参数表达(num_key_value_heads、head_dim 等),不需要单独的模型类。
这让 vLLM 的模型目录保持”一对多复用”。在这份源码里,146 个唯一 architecture key 最终映射到 114 个模型实现文件;同一个实现文件经常覆盖多个 HF architecture 名。例如 AquilaModel、InternLMForCausalLM、MistralForCausalLM、XverseForCausalLM 都路由到 llama.py 的 LlamaForCausalLM。差异通过 config 字段表达,而不是为每个名称复制一套模型类。
7.9.3 六个独立注册表:不是一个大 dict
上面伪代码把注册表描述成单个 _TEXT_GENERATION_MODELS。真实 registry.py 按模型用途分成 6 个独立 dict(registry.py:33-226):
_TEXT_GENERATION_MODELS # 主流 LLM:Llama/Qwen/Phi/Gemma/DeepSeek/Mistral 等
_EMBEDDING_MODELS # 嵌入模型:E5/BGE/Jina 等
_CROSS_ENCODER_MODELS # 重排模型(检索场景)
_MULTIMODAL_MODELS # VLM:Qwen2-VL/InternVL/LLaVA 等(第 15 章讲过)
_SPECULATIVE_DECODING_MODELS # 投机解码专用(EAGLE/Medusa/MTP 等,第 12 章)
_TRANSFORMERS_MODELS # HuggingFace transformers 万能回退
_VLLM_MODELS = {
**_TEXT_GENERATION_MODELS,
**_EMBEDDING_MODELS,
**_CROSS_ENCODER_MODELS,
**_MULTIMODAL_MODELS,
**_SPECULATIVE_DECODING_MODELS,
**_TRANSFORMERS_MODELS,
}
拆六个独立 dict 而非一个大 dict 的理由:
- 文档化模型能力分类——从注册表就能看出 “哪些是 LLM、哪些是 embedding、哪些是投机解码 helper”
- 权限隔离——加一个新 VLM 只改
_MULTIMODAL_MODELS,不会手滑改到_TEXT_GENERATION_MODELS里 - 迁移与 deprecation 单独处理——比如未来移除某一整类(某种投机解码方案 EOL),只需删一个 dict
当前版本的分类计数如下:
| 分类 | 源码位置 | 条目数说明 |
|---|---|---|
_TEXT_GENERATION_MODELS | registry.py:33-120 | 80 个显式文本生成条目 |
_EMBEDDING_MODELS | registry.py:122-159 | 25 个显式条目,再通过 dict 展开复用 8 个 Llama 实现条目 |
_CROSS_ENCODER_MODELS | registry.py:161-169 | 4 个重排/交叉编码条目 |
_MULTIMODAL_MODELS | registry.py:171-213 | 39 个多模态条目 |
_SPECULATIVE_DECODING_MODELS | registry.py:215-222 | 6 个投机解码 helper 条目 |
_TRANSFORMERS_MODELS | registry.py:224-226 | 1 个 Transformers backend 回退条目 |
这些分类合起来是 163 个条目,但有 17 个 architecture key 在多个分类中重复出现,所以 _VLLM_MODELS 的最终唯一 key 是 146 个。这个数字比”把分类行数相加”更适合写进书里,因为运行时查找的就是合并后的 dict。
_SPECULATIVE_DECODING_MODELS 展开(line 215-222):
_SPECULATIVE_DECODING_MODELS = {
"EAGLEModel": ("eagle", "EAGLE"),
"EagleLlamaForCausalLM": ("llama_eagle", "EagleLlamaForCausalLM"),
"Eagle3LlamaForCausalLM": ("llama_eagle3","Eagle3LlamaForCausalLM"),
"DeepSeekMTPModel": ("deepseek_mtp","DeepSeekMTP"),
"MedusaModel": ("medusa", "Medusa"),
"MLPSpeculatorPreTrainedModel": ("mlp_speculator","MLPSpeculator"),
}
这 6 种对应第 12 章讲的不同投机解码方案的 helper 模型——每种方案的草稿模型/预测头有自己的 class。列在独立 dict 里让它们不出现在用户的 “text generation” 模型选择里——用户不会意外地用 vllm serve EAGLEModel 跑一个 EAGLE head 当主模型(那只是个预测头、不能独立生成)。
7.9.4 TransformersForCausalLM 万能回退
_TRANSFORMERS_MODELS 只有一条(line 224-226):
_TRANSFORMERS_MODELS = {
"TransformersForCausalLM": ("transformers", "TransformersForCausalLM"),
}
这个类的功能惊人——包装任意 HuggingFace transformers 库里的模型,哪怕该模型没有专用的 vLLM 优化实现。流程:
- 用户指定某模型(比如冷门的
Starling-LM-7B-alpha)但 vLLM 注册表里没有 - vLLM 检测不到匹配,自动 fallback 到
TransformersForCausalLM - 这个 wrapper 从 transformers 库实例化模型、走基础 eager 执行路径
- 用户得到”能跑”的结果、但失去 PagedAttention / Continuous Batching / CUDA Graph 等 vLLM 优化
“vLLM 支持哪些模型” 的真实含义要分两层:合并后的 146 个 key 是 vLLM 明确知道如何解析的 architecture;注册表外但 Transformers backend 兼容的模型,可以被改写成 TransformersForCausalLM 路径(model_loader/utils.py:73-83、registry.py:449-452)。源码里的 warning 也说得很谨慎:部分特性可能不支持,性能也可能不是最优。生产上评估一个新模型时,不能只问”能不能启动”,还要问它走的是原生 vLLM 模型类,还是 Transformers 回退类。
这种”精细优化 + 兜底兼容”的双轨是 vLLM 跟上 HF 生态的关键。新 architecture 不一定一开始就有专用 load_weights()、packed module mapping、量化后处理和多模态输入处理;先走 Transformers backend 可以降低可用性门槛,后续再为热门模型补原生实现。
7.9.5 _ModelInfo 12 字段元数据与 subprocess 隔离
上面的 dict 只记录”架构名 → Python 类路径”映射,每个模型真正的能力标签存在 _ModelInfo 里(line 247-277):
@dataclass(frozen=True)
class _ModelInfo:
architecture: str
is_text_generation_model: bool
is_pooling_model: bool
supports_cross_encoding: bool
supports_multimodal: bool
supports_pp: bool # pipeline parallel
has_inner_state: bool # Mamba/RWKV 类带状态的
is_attention_free: bool # Mamba 完全无 attention
is_hybrid: bool # Jamba 类 attn+mamba 混合
has_noops: bool
supports_transcription: bool # Whisper 类
supports_v0_only: bool # 暂时没迁 V1 的模型
12 个布尔字段覆盖模型和 vLLM 框架的所有能力交集。from_model_cls 静态方法通过一系列 supports_*() 检测器自动填充这些字段——用户不需要手写、靠运行时反射模型类的 mixin 继承关系判断。
_SUBPROCESS_COMMAND 的 subprocess 隔离(line 242-244)是另一个精致细节:
_SUBPROCESS_COMMAND = [
sys.executable, "-m", "vllm.model_executor.models.registry"
]
vLLM 在 “检查一个模型类是否符合某个能力 trait” 时,会启动 subprocess 运行 registry.py 自己——因为模型 import 可能注册 CUDA 算子、touch 全局 torch state,污染主进程(比如某些模型 import 时会立即 torch.cuda.init()、导致后续 CUDA 设置变更无效)。隔离到子进程检查后,主进程启动时按用户配置决策导入——干净。
这就像 ch6 的 multiprocessing.connection.wait(sentinels) 故障监控——用进程边界做隔离是 vLLM 对”Python 全局状态 + CUDA 初始化是有毒的”这一现实的一贯应对。
7.9.6 vllm/model_executor/model_loader/ 6 文件 3193 行的真实拆分
§7.2 给出 7 种 loader,对应 loader.py 单文件里 8 个类(1 base + 7 concrete,line 193 / 210 / 476 / 503 / 603 / 792 / 1317 / 1415)——
| 文件 | 行 | 角色 |
|---|---|---|
loader.py | 1542 | 8 个 loader 类全部塞在一文件——BaseModelLoader ABC + Default / Dummy / Tensorizer / ShardedState / BitsAndBytes / GGUF / RunaiModelStreamer 7 种实现 |
weight_utils.py | 749 | _prepare_weights(§7.3.1)+ safetensors 迭代 + HF Hub 下载 + 文件锁 |
tensorizer.py | 468 | Tensorizer(§7.7.1)的独立实现层、和 loader.py:503 TensorizerLoader 配合 |
neuron.py | 243 | AWS Trainium / Inferentia 的特殊加载路径 |
utils.py | 171 | get_model_architecture()、Transformers 回退解析、dtype context 等模型选择工具 |
__init__.py | 20 | 公共 export |
三条值得记住的事实——
loader.py单文件 1542 行 = 8 个类全部塞一起——这是因为_initialize_model()、_process_weights_after_loading()、DefaultModelLoader.Source、get_model_loader()等 helper 被多个 loader 共享,拆成很多小文件未必更清晰weight_utils.py749 行——下载、文件锁、safetensors iterator、np cache、TP shard loader 都放在这里,说明”权重来源和文件格式”是跨 loader 的公共基础设施tensorizer.py独立 468 行——Tensorizer 需要自己的配置、序列化和 stream 逻辑;loader.py只保留策略入口,避免把第三方格式的细节扩散到默认加载路径
7.10 四类部署场景的加载优化配方
场景 A:单机 offline 推理(快速启动)
# Tensorizer:一次性序列化,后续用 tensorizer 路径加载
python -m vllm.entrypoints.tensorize \
--model meta-llama/Llama-3-70B \
--output /local/nvme/llama-70b.tensors
python -c "from vllm import LLM; llm = LLM(model='/local/nvme/llama-70b.tensors', load_format='tensorizer')"
这类部署的核心假设是模型权重和并行配置比较稳定。首次转换可以放到镜像构建、发布流水线或离线准备任务里;在线服务启动时只消费转换后的产物。不要把 Tensorizer 当成所有环境的默认答案:如果模型经常变、checkpoint 来自不同来源、或者排障时需要直接对照 HF 原始权重,普通 safetensors 路径更透明。
场景 B:K8s 滚动部署(多副本冷启动)
# 在 initContainer 里一次性切分到 PVC
initContainers:
- name: prepare-sharded
command: ["vllm", "save-sharded-state", "--tensor-parallel-size=4", ...]
volumeMounts: [mountPath: /shared-pvc]
# 所有副本挂载同一个 PVC
containers:
- name: vllm
command: ["vllm", "serve", "/shared-pvc/llama-70b-tp4/",
"--tensor-parallel-size=4",
"--load-format=sharded_state"]
这类部署最适合固定 TP size 的服务。initContainer 或独立 job 先生成 sharded state,业务容器只读 rank 对应文件。风险点也很明确:TP size、pipeline parallel、模型结构、量化配置任何一个改变,都要重新生成 shard;否则不是变慢,而是直接 shape mismatch 或参数缺失。
场景 C:S3 原生部署
vllm serve s3://ml-models/llama-70b/ \
--load-format runai_streamer \
--tensor-parallel-size 4
这类部署的关键不是追求一个固定秒数,而是减少”先完整下载到本地磁盘,再由 vLLM 读取”的中间步骤。对象存储吞吐、跨 AZ 网络、credential 刷新、Pod 调度位置都会影响实际启动时间;因此最好把 streamer 路径纳入压测,而不是只在单机上验证命令能跑。
场景 D:开发迭代(快速试模型)
# FP8 量化 + 本地 SSD,减少搬运数据量
vllm serve meta-llama/Llama-3-70B-FP8 \
--tensor-parallel-size 2 \
--gpu-memory-utilization 0.9
# 或用 DummyModelLoader 跳过权重加载(仅测性能)
vllm serve meta-llama/Llama-3-70B \
--load-format dummy
开发迭代场景下,目标通常不是复现生产性能,而是缩短”改代码 → 启服务 → 看到行为”的回路。dummy 可以跳过真实权重,适合验证调度、API、profile 和内存路径;FP8 或其它量化模型可以降低权重体积,但要接受量化 kernel、后处理和精度行为都会变,不能把它当作 FP16 生产路径的完全替身。
7.11 本章小结
模型加载是 LLM 推理服务的”冷启动”阶段——对用户看不见但工程上极其关键:
- 问题规模:140 GB 权重 × 30+ 分片 × 三级带宽鸿沟(NVMe → PCIe → GPU)
- 朴素实现崩点:CPU 内存 2× 爆炸、单卡装不下、weight name 不对齐、TP 切分困难
- 策略模式:7 种 Loader 适配 7 种场景;
BaseModelLoader+ 两个抽象方法保持核心代码干净 - safetensors 迭代器:按 shard、按 tensor yield,避免完整 checkpoint 一次性进入 Python state dict
- lazy 迭代器:按需 yield 单个 tensor,内存峰值 = 最大单 tensor
- QKV / gate_up 合并:HF 三个 Linear → vLLM 一个大 Linear,省 kernel launch;通过
stacked_params_mapping自动映射 - Megatron 式切分:每张卡只读自己那片,不是先 load 完再切
- 量化特殊路径:bit packing / scale / kernel 三重差异,封装在 QuantizationConfig 子类
- 三种加速方案:Tensorizer 预序列化、ShardedState 预切分、Run:ai Streamer 面向对象存储流式读取
device_loading_context:服务量化后处理和 CPU offload 的设备边界,不是普通加载的显存魔法- 模型支持:本地 v0.8.5.post1 源码中
_VLLM_MODELS合并后有 146 个唯一 architecture key;注册表数字不能直接等同于性能承诺 - 四场景配方:单机 Tensorizer / K8s ShardedState / S3 Streamer / FP8 量化
到这里,vLLM 的”数据流水线”从请求 → 调度 → 执行 → 输出 的每一环都已经走完。下一章我们继续第三篇的最后一章 ModelRunner——看看加载好的模型如何真正被调用执行。
源码导航
- Loader 基类与默认实现:
vllm/model_executor/model_loader/loader.py- 权重工具(mmap、index 解析):
vllm/model_executor/model_loader/weight_utils.py- 模型注册表:
vllm/model_executor/models/registry.py- Llama 模型实现(好的阅读起点):
vllm/model_executor/models/llama.py- 并行 Linear 层(QKV/gate_up 合并在这里定义):
vllm/model_executor/layers/linear.py- 量化配置注册:
vllm/model_executor/layers/quantization/base_config.py- Tensorizer 集成:
vllm/model_executor/model_loader/tensorizer.py- Run:ai Streamer 集成:
vllm/model_executor/model_loader/loader.py与weight_utils.py延伸阅读
- safetensors 设计文档:https://github.com/huggingface/safetensors
- CoreWeave Tensorizer:https://github.com/coreweave/tensorizer
- Megatron-LM 并行切分论文:arXiv:1909.08053