推理服务预热:冷启动不只是模型加载,JIT 编译才是延迟黑洞

📅 2026/7/13 11:02:43
推理服务预热:冷启动不只是模型加载,JIT 编译才是延迟黑洞
推理服务预热冷启动不只是模型加载JIT 编译才是延迟黑洞一、模型已加载为什么首 Token 要 8 秒推理服务启动时工程师通常只关注模型文件是否加载完成。nvidia-smi显示 GPU 显存占用 ok日志提示 Model loaded successfully然后上线。第一个请求打进去——首 Token 延迟 8.2 秒。第二个请求降至 0.3 秒。第三个请求 0.28 秒。这不是 bug是 JITJust-In-Time编译。PyTorch CUDA kernel、TensorRT 引擎、vLLM 的 CUDA Graph——这些都依赖运行时 JIT 编译。模型参数加载只是把 140GB 的权重从磁盘搬到显存JIT 编译才真正把计算图转化为可执行的 GPU 指令序列。这个过程发生在第一个推理请求到达时而非模型加载阶段。某 70B 模型的实测模型权重加载耗时 11 秒NVMe SSD PCIe Gen4首个请求 prefill 耗时 6.5 秒其中 CUDA kernel JIT 3.8 秒第二个请求 prefill 耗时 0.4 秒。如果生产流量第一个请求恰好是付费用户的高优请求——8 秒的首 Token 延迟足够触发告警、客户投诉、SRE 排班电话三重打击。基础设施不需要漂亮话。冷启动的坑在于你的模型看起来就绪了但它在等第一个请求来教它怎么干活。二、JIT 编译链与预热原理sequenceDiagram participant Disk as 模型文件 (NVMe) participant RAM as 系统内存 participant VRAM as GPU 显存 participant JIT as CUDA JIT Compiler participant Cache as CUDA Kernel Cache Note over Disk,Cache: 阶段一模型加载启动时 Disk-RAM: 读取权重文件 (11s) RAM-VRAM: cudaMemcpy 上传 (3s) Note over Disk,Cache: 阶段二首次推理JIT 触发 VRAM-JIT: 请求计算图编译 JIT-JIT: PTX → SASS 编译 (3.8s) JIT-Cache: 写入 kernel cache JIT-VRAM: 执行编译后的 kernel (0.3s) Note over Disk,Cache: 阶段三后续推理缓存命中 VRAM-Cache: 查询 kernel cache (命中) Cache-VRAM: 直接执行 (0.25s)JIT 编译链的流程是PyTorch/TensorFlow 定义的计算图 → TorchScript/TensorRT 优化图 → PTXParallel Thread ExecutionNVIDIA 中间表示→ SASSStreaming AssemblerGPU 原生指令。每一步的首次运行都需要编译。CUDA 维护了一个 kernel cache 目录通常在~/.cache/torch/或~/.nv/ComputeCache。如果这个缓存已经由预热的请求填充后续推理跳过 JIT 阶段。但不同 GPU 架构的缓存不通用——A100sm_80和 H100sm_90需要各自的编译缓存。预热不等于随便发几个请求。预热请求的 batch_size、sequence_length、beam_width 需要覆盖生产场景的实际分布。因为 CUDA kernel 的编译是参数特化的——batch_size1 和 batch_size8 可能触发不同的融合 kernelfused attention、fused layernorm 路径不同。未覆盖的参数组合在首次在线使用时仍会触发 JIT。三、预热脚本的工程化实现package warmup import ( context fmt log sync time ) // WarmupProfile 定义预热参数组合 type WarmupProfile struct { Name string // 预热阶段名称用于日志 BatchSize int // 批大小 InputTokens int // 输入 token 数 OutputTokens int // 最大输出 token 数 NumRequests int // 该组合的预热请求数 } // WarmupRunner 预热执行器 type WarmupRunner struct { profiles []WarmupProfile inferFn func(ctx context.Context, batchSize, inputTokens, outputTokens int) error warmupLog []WarmupResult } // WarmupResult 单次预热结果 type WarmupResult struct { Profile WarmupProfile Latency time.Duration Success bool ErrorMsg string } // DefaultProfiles 返回推荐的预热参数组合 // 覆盖生产环境的典型 batch_size 和 sequence_length 分布 func DefaultProfiles() []WarmupProfile { return []WarmupProfile{ // 单请求短 prompt覆盖同步在线推理 {Name: single-short, BatchSize: 1, InputTokens: 128, OutputTokens: 50, NumRequests: 3}, // 单请求长 prompt覆盖 RAG/长文场景 {Name: single-long, BatchSize: 1, InputTokens: 4096, OutputTokens: 100, NumRequests: 2}, // 中等批量覆盖活跃时段常规流量 {Name: batch-mid, BatchSize: 4, InputTokens: 512, OutputTokens: 100, NumRequests: 5}, // 大批量覆盖峰值流量 {Name: batch-large, BatchSize: 8, InputTokens: 256, OutputTokens: 50, NumRequests: 3}, // 最大批量覆盖极限场景触发临界路径 JIT {Name: batch-max, BatchSize: 16, InputTokens: 128, OutputTokens: 30, NumRequests: 2}, } } // Run 执行全部预热 func (r *WarmupRunner) Run(ctx context.Context) error { log.Println([warmup] 开始推理服务预热) var totalDuration time.Duration successCount : 0 failCount : 0 for _, profile : range r.profiles { for i : 0; i profile.NumRequests; i { start : time.Now() err : r.inferFn(ctx, profile.BatchSize, profile.InputTokens, profile.OutputTokens) latency : time.Since(start) result : WarmupResult{ Profile: profile, Latency: latency, Success: err nil, } if err ! nil { result.ErrorMsg err.Error() failCount log.Printf([warmup] 预热失败: %s round%d/%d err%v, profile.Name, i1, profile.NumRequests, err) } else { successCount log.Printf([warmup] 预热完成: %s round%d/%d latency%v, profile.Name, i1, profile.NumRequests, latency) } r.warmupLog append(r.warmupLog, result) totalDuration latency } } log.Printf([warmup] 预热结束: 成功%d 失败%d 总耗时%v, successCount, failCount, totalDuration) if failCount 0 { return fmt.Errorf(warmup partially failed: %d/%d failures, failCount, successCountfailCount) } return nil } // ParallelRun 并行预热缩短总耗时 // 注意并行预热会同时触发多个 CUDA kernel 编译可能竞争 GPU 资源。 // 仅在需要快速启动且 GPU 资源充裕时使用。 func (r *WarmupRunner) ParallelRun(ctx context.Context) error { var wg sync.WaitGroup errCh : make(chan error, len(r.profiles)) for _, profile : range r.profiles { wg.Add(1) go func(p WarmupProfile) { defer wg.Done() for i : 0; i p.NumRequests; i { if err : r.inferFn(ctx, p.BatchSize, p.InputTokens, p.OutputTokens); err ! nil { errCh - fmt.Errorf(profile %s: %w, p.Name, err) return } } }(profile) } wg.Wait() close(errCh) // 收集所有错误 var errs []error for err : range errCh { errs append(errs, err) } if len(errs) 0 { return fmt.Errorf(warmup errors: %v, errs) } return nil }关键设计点参数覆盖策略预热 batch_size 1/4/8/16 覆盖单请求到批量推理input_tokens 128/256/512/4096 覆盖短 prompt 到长文场景。预热顺序先执行单请求触发基础 kernel JIT再执行批量请求触发 batch 特化 kernel避免同时编译造成 GPU 超时。并行预热警示ParallelRun仅在 GPU 资源充裕时使用。并行触发多个 CUDA kernel 编译可能导致CUDA_ERROR_LAUNCH_TIMEOUT。四、预热时间、缓存失效与僵尸请求的权衡预热延长启动时间。完整的预热流程覆盖 5 种 batch × 5 种 seq_length 25 种组合每种 3 次请求可能耗时 30~60 秒。这意味着服务的滚动更新周期延长。如果使用 Kubernetes Rolling Update需将terminationGracePeriodSeconds延长以覆盖预热时间。CUDA Cache 失效。Docker 容器重建时~/.cache/torch/目录的内容会丢失。每次滚动更新后都需要重新预热。缓解方案是将 GPU 节点的 cache 目录通过 hostPath 持久化到宿主机多容器共享# k8s pod spec 片段 volumes: - name: cuda-cache hostPath: path: /var/cache/cuda type: DirectoryOrCreate volumeMounts: - name: cuda-cache mountPath: /root/.cache/torch僵尸请求的副作用。预热请求的结果并不关心——只需要它们触发 JIT 编译。但这些请求仍会占用 GPU 计算周期和显存。如果预热请求的 max_tokens 过大生成的 token 序列会挤占 KV Cache 空间影响刚启动时的可用容量。不适用场景每次启动后都立即重建 CUDA cache 的环境如无状态容器、CI/CD 流水线中的临时推理实例已经使用 TensorRT Engine 预编译的推理服务Engine 文件在构建阶段已完成编译无需运行时 JITGPU 型号与 Cache 不匹配的异构集群无法共享 kernel cache。五、总结推理服务的真正冷启动耗时 模型加载 JIT 编译。JIT 编译是首个请求延迟爆炸的根源。通过预热策略——在服务注册到负载均衡前发送覆盖实际参数分布的预热请求——可以将首请求延迟从 8 秒降至 0.3 秒。落地步骤第一步配置预热脚本为 Pod 启动的 postStart hook 或 init 容器的后续步骤第二步在健康检查就绪探针中增加预热完成标记确保负载均衡不会向未预热实例分配流量第三步持久化 CUDA cache 目录避免每次重启都从头编译。