模型加载与冷启动的分段:权重反序列化、显存画像与图捕获
在一台 8 卡 **** 的 ****(8 卡,单卡 64GB HBM,卡间互联 392 GB/s)上,用 **** 的基础软件栈配合 vLLM(v<版本>,v0 引擎)起一个 OpenAI 兼容服务,模型取 BF16 的 70B 量级,--tensor-parallel-size 8、--gpu-memory-utilization 0.90,其余取默认。从进程启动到第一个请求返回首 token 之间的墙钟时间明显长于预期,而 stdout 在这段时间里只打出几行日志,从输出上看不出耗时落在哪一段。压测脚本把 TTFT 的计时起点定在请求发出之后,服务尚未就绪时的连接重试不计入统计,冷启动成本因此在服务指标上不可见。把启动过程按日志切成五段、分别度量权重加载与反序列化、显存画像、KV 池确定、CUDA graph 捕获与暖机,是回答这个问题的基本动作。
冷启动的五个阶段
| 阶段 | 入口符号 | 结束时能读到的日志行 |
|---|---|---|
| 引擎与执行器初始化 | EngineArgs.create_engine_config、LLMEngine 构造 |
无专门的耗时行,tokenizer 与 config 加载后进入下一段 |
| 权重加载与反序列化 | DefaultModelLoader → safetensors_weights_iterator |
Loading model weights took %.4f GB |
| 显存画像 | Worker.determine_num_available_blocks → model_runner.profile_run() |
画像失败时报 Error in memory profiling. ... |
| KV 池确定与分配 | Worker.determine_num_available_blocks 尾部 |
# GPU blocks: %d, # CPU blocks: %d |
| 图捕获与暖机 | Worker._warm_up_model → model_runner.capture_model() |
Capturing the model for CUDA graphs. ...、Graph capturing finished in %.0f secs. |
权重加载与反序列化
--load-format 决定走哪条加载路径。auto、pt、safetensors、npcache 四个取值都落到 DefaultModelLoader,由 _get_weights_iterator 再分流:safetensors 走 safetensors_weights_iterator,按 mmap 打开分片文件、逐张量拷贝到 device;pt 走 pt_weights_iterator,用 torch.load 反序列化整份 checkpoint。dummy、tensorizer、bitsandbytes 各有独立 loader。加载段被 CudaMemoryProfiler 包住,结束时打印权重显存占用(self.model_memory_usage),这条日志是这一段的唯一边界。
这一段的时间由权重体积与存储读带宽决定:先按分片读盘或读网络存储,再逐张量拷入 device,两者串行。它是五个阶段里唯一随模型体积线性增长的一段。
显存画像与 KV 池
显存画像用一次真实前向完成。profile_run() 按 --max-num-batched-tokens 与 --max-num-seqs 构造 dummy 输入(SamplingParams(top_p=0.99, top_k=vocab_size-1)),跑一遍模型后 torch.cuda.synchronize(),用于测峰值激活显存。注意画像用的是 dummy 输入,权重仍是真实权重;随机权重只在 --load-format dummy 时才启用(initialize_dummy_weights)。
KV 池容量由画像结果换算,不做额外计算:
num_gpu_blocks = (total_gpu_memory * gpu_memory_utilization - peak_memory) // cache_block_size
num_cpu_blocks = swap_space_bytes // cache_block_size
peak_memory 取 init_gpu_memory - free_gpu_memory。画像阶段显存没有正确回收时会断言失败,报 Error in memory profiling. This happens when the GPU memory was not properly cleaned up before initializing the vLLM instance.,这条是区分「画像没跑起来」与「画像跑完但池子不够」的分界。
图捕获与暖机
_warm_up_model 在 --enforce-eager 为假时调用 capture_model,按 batch_size_capture_list 逐个 batch size 捕获 CUDA graph,覆盖的上下文长度上限由 --max-seq-len-to-capture(默认 8192)决定,超出的请求回落 eager。框架在这一段打印两行提示:Capturing the model for CUDA graphs. ...,以及 CUDA graphs can take additional 1~3 GiB memory per GPU. If you are running out of memory, consider decreasing gpu_memory_utilization or enforcing eager mode.,捕获完成后打印 Graph capturing finished in %.0f secs.。暖机的其余动作是重置随机种子(set_random_seed),保证初始化与画像不污染后续采样的随机状态。
参数与取值
| 参数 | 默认值 | 作用 |
|---|---|---|
--model |
必填 | 权重目录或模型标识 |
--load-format |
auto |
choices:auto / pt / safetensors / npcache / dummy / tensorizer / bitsandbytes |
--dtype |
auto |
choices:auto / half / float16 / bfloat16 / float / float32 |
--tensor-parallel-size |
1 | 权重按此并行度切分 |
--gpu-memory-utilization |
0.90 | 显存预算比例,直接进入 KV 池容量公式 |
--max-model-len |
由模型配置推导 | 未指定时取模型自身上限 |
--max-num-batched-tokens |
未指定时推导 | 开分块预填充取 512,否则取 max(max_model_len, 2048) |
--max-num-seqs |
256 | 单步并发序列上限,决定画像规模 |
--block-size |
16 | choices:8 / 16 / 32 |
--swap-space |
4(GiB) | 决定 num_cpu_blocks |
--enforce-eager |
关 | 为真则整段跳过图捕获 |
--max-seq-len-to-capture |
8192 | 图覆盖的上下文上限 |
--num-gpu-blocks-override |
未指定 | 指定后忽略画像结果、直接采用该块数,用于测抢占 |
--enable-chunked-prefill |
关 | 该版本需显式开启,开启后 --max-num-batched-tokens 默认改为 512 |
--enable-prefix-caching |
关 | 前缀缓存,影响画像与块分配 |
--disable-log-requests |
关 | 关闭逐请求日志 |
触发条件与现象
- 权重段主导:模型体积大而存储读带宽低时,
Loading model weights took之后的墙钟占比上升;换存储后同一条日志的 GB 数不变、耗时变。 - 图捕获段主导:
--enforce-eager被关掉(默认)时,Graph capturing finished的时间随捕获的 batch size 列表长度增长;每卡额外占用 1~3 GiB,可能与 KV 池争夺同一笔预算。 - 画像段失败:
Error in memory profiling. ...断言触发,进程在 KV 池分配前退出。 - KV 池偏小:
# GPU blocks数值偏小,服务能起但并发一高就触发抢占与重算。 - 权重被量化:
--dtype或--quantization改变每参数字节数,权重体积与加载段同步变化。 - 空跑对照:用
--load-format dummy起服务,权重段接近零,其余四段的时间不受影响。
定位步骤
- 抓启动日志,取三条边界行:
Loading model weights took、# GPU blocks: %d, # CPU blocks: %d、Graph capturing finished in %.0f secs.。 - 对照权重段:读
Loading model weights took的 GB 数与配置的--dtype是否一致;GB 数由参数量 × 字节数决定,先确认加载的是预期的精度。 - 隔离图捕获段:用
--enforce-eager再起一次,两次启动的墙钟差值即图捕获与暖机段的量级。 - 隔离权重段:用
--load-format dummy起一次,权重读取接近零,剩下的墙钟即画像、KV 池分配与引擎初始化。 - 区分画像失败与池子不足:搜
Error in memory profiling;有该行说明画像没完成,无该行而# GPU blocks偏小说明画像完成但预算被压缩。 - 固定块数做对照:加
--num-gpu-blocks-override后服务能起,说明启动失败来自画像得出的块数;仍失败则与块数无关。
# 起服务并记录启动边界(参数名与日志字段取自引擎,取值按本机规格填)
python -m vllm.entrypoints.openai.api_server \
--model <model-path> \
--tensor-parallel-size 8 \
--gpu-memory-utilization 0.90 \
--served-model-name <name> \
--disable-log-requests 2>&1 | tee startup.log
# 隔离图捕获段
python -m vllm.entrypoints.openai.api_server --model <model-path> --enforce-eager
# 隔离权重加载段
python -m vllm.entrypoints.openai.api_server --model <model-path> --load-format dummy
处置
| 方向 | 动作 | 代价 / 前提 |
|---|---|---|
| 权重段过长 | 提高存储读带宽,或改用按需读取的加载格式 | 需要重跑一次启动验证 |
| 图捕获段过长 | --enforce-eager 跳过捕获 |
decode 与首 token 变慢 |
| 图显存挤占 KV 池 | 调低 --max-seq-len-to-capture |
长上下文请求回落 eager |
| 画像峰值过高 | 调低 --max-num-batched-tokens 或 --max-num-seqs |
单步批变小,吞吐下降 |
| KV 池偏小 | 提高 --gpu-memory-utilization 或加 --tensor-parallel-size |
需重新画像,留出激活余量 |
| 冷启动要可复现 | 固定 --seed 与全部显存相关开关 |
记录字段须写全 |
记录字段
# 一条冷启动记录(字段名示意)
model: # 模型标识
dtype: # 权重精度,如 bfloat16
quantization: # 量化方案,无则 None
tensor_parallel_size:
gpu_memory_utilization:
load_format: # auto / safetensors / dummy ...
max_model_len:
max_num_batched_tokens:
max_num_seqs:
block_size:
swap_space:
enforce_eager: # 是否跳过图捕获
max_seq_len_to_capture:
num_gpu_blocks_override:
phases:
weights_gb: # 取自 Loading model weights took
num_gpu_blocks: # 取自 # GPU blocks
num_cpu_blocks:
graph_capture_secs: # 取自 Graph capturing finished in
startup_wall_secs: # 进程启动到服务就绪
device: # 卡数与单卡显存
status: # 正常 / 画像失败 / 池子不足
小结
冷启动按日志可切成五段:权重加载与反序列化、显存画像、KV 池确定、图捕获、暖机。权重段由体积与存储带宽决定,是唯一随模型体积增长的一段;画像段由 --max-num-batched-tokens 与 --max-num-seqs 决定,其峰值进入 KV 池容量公式;图捕获段由捕获的 batch size 列表与 --max-seq-len-to-capture 决定。定位时用 --enforce-eager 与 --load-format dummy 两次对照把图捕获段与权重段分别隔离,用 Error in memory profiling 与 # GPU blocks 两行区分画像失败和池子不足。