执行摘要
- 一句话:LLaVA 图像加载移出 CPU 超时预算,修复 flaky 500
- 推荐动作:建议精读
llava.py 中 _process_single_image、_fetch_remote_image_bytes、_preprocess_image_task 的拆分方式,这是异步超时预算与执行器池结合设计的典型案例;修复有量化证据但缺少固化测试,可关注后续是否补测,并在文档中补充 SGLANG_MM_LOAD_MAX_RETRIES 说明。
功能与动机
PR body 指出 test_mixed_batch 因 raw.githubusercontent.com 在 CI 网络拥塞下获取慢导致 HTTP 500;ProcessPoolExecutor 的 future 一旦运行无法取消,超时后 worker 仍占用池,12 个并发请求会在 4 线程客户端下打满池并级联失败,这同时是影响所有 LLaVA 家族模型的生产稳健性缺陷。其他 VLM 走 BaseMultimodalProcessor.load_mm_data 已分离 io 与 CPU 工作,因此只有 LLaVA 暴露此问题。
实现拆解
- 拆分任务函数:原静态方法
_process_single_image_task 重命名为 _preprocess_image_task,去掉网络获取逻辑,只接收已获取的图片输入(远程压缩字节、本地路径或内联字节),按 pad / anyres 分支完成解码与预处理。改动集中在 python/sglang/srt/multimodal/processors/llava.py。
- 新增 io 池获取函数:
_fetch_remote_image_bytes 通过 self.io_executor 调用 get_image_bytes,对 requests.exceptions.Timeout 与 ConnectionError 做最多 SGLANG_MM_LOAD_MAX_RETRIES(默认 2)次重试,指数退避从 0.5 秒翻倍;每次尝试内部由 download_remote_media 的 REQUEST_TIMEOUT 兜底,总时长有界。
- 重新编排
_process_single_image:先统一提取 URL 并预计算 image_hash;远程图片先经 io 池取回压缩字节再交给 CPU 池,本地/内联输入直接进 CPU 池;asyncio.wait_for 现在只约束 CPU 段,网络等待不再挤占该预算。
- 配套与验证:无新增配置键(只有环境变量),未新增测试文件;PR 通过故障注入(1×H200、p=0.12 慢获取、
REQUEST_TIMEOUT=3、SGLANG_CPU_WORKERS=4、禁用客户端重试)各跑 100 次对比,每请求 500 率从 7.95%(159/2001)降为 0(0/1200),test_mixed_batch 单次失败率 67%→0;真实 CI 测试 test_vision_openai_server_a.py -k TestLlavaServer 6 passed。
关键文件:
python/sglang/srt/multimodal/processors/llava.py(模块 多模态;类别 source;类型 core-logic;符号 _process_single_image_task, _preprocess_image_task, _fetch_remote_image_bytes, _process_single_image): 唯一变更文件,将 LLaVA 图片的网络获取从 CPU 预处理超时预算中拆出,引入 io 线程池获取与指数退避重试,是 flaky 修复与生产稳健性改进的核心。
关键符号:_fetch_remote_image_bytes, _preprocess_image_task, _process_single_image
关键源码片段
python/sglang/srt/multimodal/processors/llava.py
唯一变更文件,将 LLaVA 图片的网络获取从 CPU 预处理超时预算中拆出,引入 io 线程池获取与指数退避重试,是 flaky 修复与生产稳健性改进的核心。
# python/sglang/srt/multimodal/processors/llava.py(head 版本整理)
import asyncio
import os
import requests
from sglang.srt.utils import ImageData, get_image_bytes, load_image
class LlavaImageProcessor(BaseMultimodalProcessor):
@staticmethod
def _preprocess_image_task(image_input, image_hash, image_aspect_ratio,
image_grid_pinpoints, processor=None):
# CPU 侧解码 + 预处理。此时 image_input 是已获取的压缩字节(远程)
# 或本地 / 内联输入,不会发生网络等待,适合放在 ProcessPool 并行执行。
image_processor = processor.image_processor
try:
image, image_size = load_image(image_input, False)
if image_size is not None:
# 视频或多帧场景:逐帧预处理后堆叠
pixel_values = image_processor(image)["pixel_values"]
for i in range(len(pixel_values)):
pixel_values[i] = ensure_numpy(pixel_values[i]).astype(np.float16)
return np.stack(pixel_values, axis=0), image_hash, image_size
# 普通图片:按 aspect_ratio 分支处理
if image_aspect_ratio == "pad":
image = expand2square(
image, tuple(int(x * 255) for x in image_processor.image_mean)
)
pixel_values = image_processor(image.convert("RGB"))["pixel_values"][0]
elif image_aspect_ratio == "anyres" or "anyres_max" in (image_aspect_ratio or ""):
pixel_values = process_anyres_image(
image, image_processor, image_grid_pinpoints
)
else:
pixel_values = image_processor(image)["pixel_values"][0]
pixel_values = ensure_numpy(pixel_values)
if isinstance(pixel_values, np.ndarray):
pixel_values = pixel_values.astype(np.float16)
return pixel_values, image_hash, image.size
except Exception:
logger.error("Exception in TokenizerManager:\n" + get_exception_traceback())
async def _fetch_remote_image_bytes(self, url):
# 网络获取移到 io 线程池,避免占用 CPU 池;只重试瞬态网络错误。
# 每次尝试由 download_remote_media 内部的 REQUEST_TIMEOUT 兜底。
loop = asyncio.get_running_loop()
max_retries = max(0, int(os.environ.get("SGLANG_MM_LOAD_MAX_RETRIES", "2")))
delay = 0.5
for attempt in range(max_retries + 1):
try:
return await loop.run_in_executor(self.io_executor, get_image_bytes, url)
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError):
if attempt >= max_retries:
raise
await asyncio.sleep(delay)
delay *= 2
async def _process_single_image(self, image_data, aspect_ratio, grid_pinpoints):
# 统一提取 URL 并预计算哈希;远程图片先由 io 池拿压缩字节,
# CPU 池只做解码与预处理,asyncio.wait_for 不再包含网络等待。
url = image_data.url if isinstance(image_data, ImageData) else image_data
image_hash = hash(url)
if isinstance(image_data, ImageData) and image_data.url:
image_input = await self._fetch_remote_image_bytes(url)
else:
image_input = image_data # 本地路径或内联字节
loop = asyncio.get_running_loop()
fut = loop.run_in_executor(
self.cpu_executor,
LlavaImageProcessor._preprocess_image_task,
image_input, image_hash, aspect_ratio, grid_pinpoints, self._processor,
)
timeout = int(os.environ.get("REQUEST_TIMEOUT", "10"))
return await asyncio.wait_for(fut, timeout=timeout)
评论区精华
第二轮评审(记录在 commit 089be56b)指出,先前版本在 io 池加载图片后把懒加载 PIL 对象传入 ProcessPool,pickle 会迫使解码发生在 tokenizer 进程,并把原始像素跨进程传输(约 100 倍压缩大小),序列化与内存开销巨大。结论是 io 池只取压缩字节、CPU 池负责解码,跨进程数据量最小化。此外 CI 反复触发 /rerun-test test/registered/vlm/test_vision_openai_server_a.py,多次验证均通过,证明 flaky 修复在真实 CI 网络下稳定。
- io 池与 CPU 池的分工与 pickle 序列化问题 (design): 改为 io 池只取压缩字节,CPU 池负责解码与预处理,跨进程数据量最小化。
风险与影响
- 风险:
- 异常传播行为变化:此前网络异常会被
_process_single_image_task 的 try/except 捕获并记录日志后返回 None;现在 _fetch_remote_image_bytes 对非 Timeout/ConnectionError 异常(如 HTTP 404)会直接上抛,最终请求以 500 结束,从“静默失败”变为“显式失败”,需确认上层对 None 的容错逻辑不会引入空指针类问题。
- 重试导致最坏延迟上升:默认
SGLANG_MM_LOAD_MAX_RETRIES=2,每次请求最多 3 次网络尝试,最坏情况下单请求图片加载时长约为 3 × REQUEST_TIMEOUT + 退避等待,对端到端延迟敏感场景需评估。
- 缺少固化测试:故障注入实验是 PR body 中的手工基准,未沉淀为仓库内单元/集成测试,后续重构执行器或超时逻辑时有回归风险。
- 影响范围:变更只涉及 LLaVA 家族处理器(LlavaLlama / LlavaVid / LlavaQwen / LlavaMistral)的图片加载路径,其他 VLM 不受影响。
- 影响:对用户:修复 LLaVA 图片源瞬态缓慢时的 HTTP 500,生产稳定性提升,重试机制增强对瞬态网络错误的容错。对系统:io/cpu 分工更明确,ProcessPool 不再被慢网络请求占满,消除级联 500;跨进程只传压缩字节,传输数据量约减小 100 倍。对团队:为多模态处理器提供与 BaseMultimodalProcessor 一致的 io/cpu 分离模式,新环境变量 SGLANG_MM_LOAD_MAX_RETRIES 值得纳入文档。
- 风险标记:核心路径变更, 异常传播行为变化, 重试最坏延迟上升, 缺少固化测试
关联脉络
参与讨论