模型加载与冷启动的分段:权重反序列化、显存画像与图捕获

在一台 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 关 关闭逐请求日志

触发条件与现象

定位步骤

  1. 抓启动日志,取三条边界行:Loading model weights took、# GPU blocks: %d, # CPU blocks: %d、Graph capturing finished in %.0f secs.。
  2. 对照权重段:读 Loading model weights took 的 GB 数与配置的 --dtype 是否一致;GB 数由参数量 × 字节数决定,先确认加载的是预期的精度。
  3. 隔离图捕获段:用 --enforce-eager 再起一次,两次启动的墙钟差值即图捕获与暖机段的量级。
  4. 隔离权重段:用 --load-format dummy 起一次,权重读取接近零,剩下的墙钟即画像、KV 池分配与引擎初始化。
  5. 区分画像失败与池子不足:搜 Error in memory profiling;有该行说明画像没完成,无该行而 # GPU blocks 偏小说明画像完成但预算被压缩。
  6. 固定块数做对照:加 --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 两行区分画像失败和池子不足。