Skip to content

Commit bbceff0

Browse files
sufubaohiworldwzj
andauthored
feat(visual): reserve ViT worst-case activation memory (#1378)
Co-authored-by: wangzaijun <wzjhelloworld@qq.com>
1 parent 2d5c057 commit bbceff0

5 files changed

Lines changed: 198 additions & 43 deletions

File tree

lightllm/models/qwen_vl/qwen_visual.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from typing import Callable, Optional, Sequence, Tuple, List, Union
1313
import numpy as np
1414
from lightllm.server.embed_cache.utils import read_shm, get_shm_name_data
15+
from lightllm.server.multimodal_params import ImageItem
1516
import torch
1617
from torch import nn
1718
from torch.nn import functional as F
@@ -413,22 +414,27 @@ def forward(self, x: torch.Tensor):
413414

414415
return x
415416

416-
def encode(self, image_uuids: List):
417+
def encode(self, images: List[Union[int, ImageItem]]):
417418
img_tensors = []
418419
uuids = []
419420
valid_id = 0
420421
valid_ids = []
421422

422-
for i, item in enumerate(image_uuids):
423-
if isinstance(item, int):
424-
uuids.append(item)
425-
image_data = read_shm(get_shm_name_data(item))
426-
image_data = Image.open(BytesIO(image_data)).convert("RGB")
427-
t = self.image_transform(image_data)
428-
img_tensors.append(t)
423+
for i, item in enumerate(images):
424+
# Legacy path: uuid int. Current visualserver / peak-hold path: ImageItem.
425+
if isinstance(item, ImageItem):
426+
uuid = item.uuid
427+
elif isinstance(item, int):
428+
uuid = item
429429
else:
430430
raise Exception("Unsupported input types: {} for {}".format(type(item), item))
431431

432+
uuids.append(uuid)
433+
image_data = read_shm(get_shm_name_data(uuid))
434+
image_data = Image.open(BytesIO(image_data)).convert("RGB")
435+
t = self.image_transform(image_data)
436+
img_tensors.append(t)
437+
432438
valid_ids.append([valid_id, valid_id + 1])
433439
valid_id += 1
434440
if len(img_tensors) <= 0:

lightllm/models/vit/model.py

Lines changed: 11 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import torchvision.transforms as T
1515
from lightllm.server.embed_cache.utils import read_shm, get_shm_name_data
1616
from PIL import Image
17-
from typing import List, Union, final
17+
from typing import List, Union
1818
from io import BytesIO
1919
from rpyc.utils.classic import obtain
2020
from lightllm.common.quantization import Quantcfg
@@ -26,7 +26,6 @@
2626

2727

2828
class VisionTransformer:
29-
3029
# weight class
3130
pre_and_post_weight_class = ViTPreAndPostLayerWeight
3231
transformer_weight_class = ViTTransformerLayerWeight
@@ -53,30 +52,6 @@ def __init__(self, kvargs):
5352
self._init_quant()
5453
self._init_weights()
5554
self._init_infer_layer()
56-
self._check_max_len_infer()
57-
return
58-
59-
@final
60-
@torch.no_grad()
61-
def _check_max_len_infer(self):
62-
disable_check_max_len_infer = os.getenv("DISABLE_CHECK_MAX_LEN_INFER", None) is not None
63-
if disable_check_max_len_infer:
64-
return
65-
66-
try:
67-
dummy_images = torch.randn(
68-
(self.MAX_PATH_NUM * self.max_batch_size, 3, self.IMAGE_H, self.IMAGE_W), dtype=self.data_type
69-
).cuda()
70-
all_img_embeds = self.forward(dummy_images)
71-
del all_img_embeds
72-
logger.info(f"vit check max_len {self.max_batch_size} infer ok")
73-
except (RuntimeError, torch.OutOfMemoryError) as e:
74-
logger.exception(str(e))
75-
exception_str = (
76-
"Vit check max len infer fail, you can try:" "1.Set the --visual_infer_batch_size to a smaller value."
77-
)
78-
logger.error(exception_str)
79-
raise Exception(exception_str)
8055
return
8156

8257
def _init_config(self):
@@ -170,29 +145,33 @@ def forward(self, pixel_values):
170145
@torch.no_grad()
171146
def encode(self, images: List[ImageItem]):
172147
img_tensors = []
173-
valid_ids = []
174-
valid_id = 0
175148
uuids = []
149+
patch_nums = []
176150
for i, img in enumerate(images):
177151
if isinstance(img, ImageItem):
178152
uuids.append(img.uuid)
179153
image_data = read_shm(get_shm_name_data(img.uuid))
180154
image_data = Image.open(BytesIO(image_data))
181155
t = self.load_image_func(image_data, max_num=img.extra_params["image_patch_max_num"])
182156
img_tensors.append(t)
157+
patch_nums.append(t.shape[0])
183158
else:
184159
raise Exception("Unsupported input types: {} for {}".format(type(img), img))
185160

186-
cur_num = img.token_num
187-
valid_ids.append([valid_id, valid_id + cur_num])
188-
valid_id += cur_num
189-
190161
if len(img_tensors) <= 0:
191162
return None
192163

193164
imgs = torch.cat(img_tensors, dim=0)
194165
pixel_values = imgs.cuda().to(dtype=self.data_type)
166+
# [total_patches, tokens_per_patch, hidden]
195167
all_img_embeds = self.forward(pixel_values)
168+
tokens_per_patch = all_img_embeds.shape[1]
169+
valid_ids = []
170+
valid_id = 0
171+
for n_patches in patch_nums:
172+
cur_num = n_patches * tokens_per_patch
173+
valid_ids.append([valid_id, valid_id + cur_num])
174+
valid_id += cur_num
196175
return all_img_embeds.view(-1, all_img_embeds.shape[-1]), uuids, valid_ids
197176

198177
def cuda(self):

lightllm/server/api_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ def make_argument_parser() -> argparse.ArgumentParser:
450450
parser.add_argument(
451451
"--max_image_pixels",
452452
type=int,
453-
default=8294400,
453+
default=3686400, # 8294400 is 4k, 3686400 is 2k
454454
help="maximum allowed pixel count for one image before resize preprocessing",
455455
)
456456
parser.add_argument(

lightllm/server/visualserver/model_infer/model_rpc.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
import rpyc
23
import torch
34
import socket
@@ -11,7 +12,6 @@
1112
from rpyc.utils.classic import obtain
1213
from lightllm.models.qwen_vl.qwen_visual import QWenVisionTransformer
1314
from lightllm.models.llava.llava_visual import LlavaVisionModel
14-
from lightllm.models.internvl.internvl_visual import InternVLVisionModel
1515
from lightllm.models.gemma3.gemma3_visual import Gemma3VisionModel
1616
from lightllm.models.gemma4.gemma4_visual import Gemma4VisionModel
1717
from lightllm.models.vit.model import VisionTransformer
@@ -28,6 +28,7 @@
2828
from lightllm.server.visualserver import set_vit_att_backend
2929
from lightllm.server.embed_cache.afs_utils import SepEmbedHandler
3030
from lightllm.utils.log_utils import init_logger
31+
from lightllm.server.visualserver.model_infer.vision_peak_vram_hold import VisionPeakVramHolder
3132

3233

3334
logger = init_logger(__name__)
@@ -95,7 +96,6 @@ def exposed_init_model(self, kvargs):
9596
self.model = LlavaVisionModel()
9697
elif self.model_type == "internvl_chat":
9798
self.model = VisionTransformer(kvargs)
98-
# self.model = InternVLVisionModel()
9999
elif self.model_type == "gemma3":
100100
self.model = Gemma3VisionModel()
101101
elif self.model_type == "gemma4":
@@ -114,6 +114,7 @@ def exposed_init_model(self, kvargs):
114114

115115
self.model.load_model(weight_dir)
116116
self.model = self.model.cuda()
117+
self._hold_vision_peak_vram()
117118
if not self.is_visual_only_mode:
118119
self.cache_client = rpyc.connect("localhost", self.cache_port, config={"allow_pickle": True})
119120
self.cache_client._channel.stream.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
@@ -143,6 +144,34 @@ def exposed_init_model(self, kvargs):
143144
set_random_seed(2147483647)
144145
return
145146

147+
def _hold_vision_peak_vram(self):
148+
"""通过 warmup 预先保留 ViT 推理所需的最大显存。
149+
150+
ViT 与 LLM 同卡时,LLM 启动会按 mem_get_info 划分 KV pool。若此时 ViT 尚未跑过
151+
峰值 activation,LLM 可能多占显存;之后大图 ViT 推理会因启动顺序不同而 OOM。
152+
这里用 worst-case encode warmup,提前占住 ViT 峰值显存,避免后续不够用。
153+
"""
154+
args = get_env_start_args()
155+
if os.getenv("DISABLE_VISION_PEAK_VRAM_WARMUP", None) is not None:
156+
logger.warning(
157+
"DISABLE_VISION_PEAK_VRAM_WARMUP is set: skipping vision peak VRAM hold. "
158+
"A co-located LLM may OOM at runtime."
159+
)
160+
return
161+
held_vram_bytes = VisionPeakVramHolder(self.model).hold(
162+
self.device_id,
163+
self.infer_max_batch_size,
164+
args.max_image_pixels,
165+
args.max_image_token_count,
166+
tp_rank_id=self.tp_rank_id,
167+
vit_tp=self.vit_tp,
168+
dp_rank_id=self.dp_rank_id,
169+
)
170+
if held_vram_bytes > 0:
171+
logger.info(
172+
f"Vision model on device {self.device_id} held " f"{held_vram_bytes / 1024 ** 3:.2f} GB peak VRAM."
173+
)
174+
146175
def exposed_run_task(self, images: List["ImageItem"], ref_event_list: List[threading.Event]):
147176
try:
148177
images = obtain(images)
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Hold vision-model peak VRAM when co-located with the LLM.
2+
3+
Build worst-case ImageItem inputs (max pixel budget, extreme aspect ratio, solid RGB),
4+
run the normal encode() path, then keep the CUDA allocator high-water mark.
5+
"""
6+
7+
import math
8+
import torch
9+
import torch.distributed as dist
10+
from io import BytesIO
11+
from typing import List, Tuple
12+
from PIL import Image
13+
from lightllm.server.embed_cache.utils import create_shm, free_shm, get_shm_name_data
14+
from lightllm.server.multimodal_params import ImageItem
15+
from lightllm.utils.envs_utils import get_unique_server_name
16+
from lightllm.utils.log_utils import init_logger
17+
18+
logger = init_logger(__name__)
19+
20+
_PEAK_VRAM_HOLD_OOM_HINT = (
21+
"Vision peak VRAM hold probe hit OOM. Lower --visual_infer_batch_size, "
22+
"--max_image_pixels, or --max_image_token_count, or place the vision model on a "
23+
"separate GPU with --visual_gpu_ids."
24+
)
25+
26+
27+
class VisionPeakVramHolder:
28+
"""Hold peak vision VRAM at startup via worst-case ImageItem encode()."""
29+
30+
# Match Qwen-VL smart_resize MAX_RATIO; extreme aspect ratio stresses vision sequence length.
31+
MAX_ASPECT_RATIO = 200
32+
33+
def __init__(self, model):
34+
self.model = model
35+
36+
@torch.no_grad()
37+
def hold(
38+
self,
39+
device_id: int,
40+
batch_size: int,
41+
max_image_pixels: int,
42+
max_image_token_count: int,
43+
tp_rank_id: int = 0,
44+
vit_tp: int = 1,
45+
dp_rank_id: int = 0,
46+
) -> int:
47+
torch.cuda.set_device(device_id)
48+
baseline_reserved = torch.cuda.memory_reserved(device_id)
49+
torch.cuda.reset_peak_memory_stats(device_id)
50+
51+
gloo_group = dist.new_group(ranks=list(range(vit_tp)), backend="gloo")
52+
53+
image_items = self._prepare_worst_case_image_items(
54+
batch_size=batch_size,
55+
max_image_pixels=max_image_pixels,
56+
max_image_token_count=max_image_token_count,
57+
tp_rank_id=tp_rank_id,
58+
vit_tp=vit_tp,
59+
dp_rank_id=dp_rank_id,
60+
gloo_group=gloo_group,
61+
)
62+
63+
try:
64+
out = self.model.encode(image_items)
65+
del out
66+
except (RuntimeError, torch.OutOfMemoryError) as e:
67+
logger.exception(str(e))
68+
raise Exception(_PEAK_VRAM_HOLD_OOM_HINT)
69+
finally:
70+
dist.barrier(group=gloo_group)
71+
72+
if tp_rank_id == 0:
73+
self._free_image_items(image_items)
74+
75+
peak_reserved = torch.cuda.max_memory_reserved(device_id)
76+
return int(max(0, peak_reserved - baseline_reserved))
77+
78+
def _worst_case_image_size(self, max_image_pixels: int) -> Tuple[int, int]:
79+
"""Largest valid landscape aspect ratio under the pixel budget (width >= height)."""
80+
height = max(1, int(math.sqrt(max_image_pixels / self.MAX_ASPECT_RATIO)))
81+
width = min(max_image_pixels // height, height * self.MAX_ASPECT_RATIO)
82+
height = int(max(1, height))
83+
width = int(max(1, width))
84+
return width, height
85+
86+
def _gen_rgb_jpeg_bytes(self, width: int, height: int, color: Tuple[int, int, int] = (0, 0, 0)) -> bytes:
87+
buffer = BytesIO()
88+
Image.new("RGB", (width, height), color).save(buffer, format="JPEG", quality=95)
89+
return buffer.getvalue()
90+
91+
def _prepare_worst_case_image_items(
92+
self,
93+
batch_size: int,
94+
max_image_pixels: int,
95+
max_image_token_count: int,
96+
tp_rank_id: int,
97+
vit_tp: int,
98+
dp_rank_id: int,
99+
gloo_group,
100+
) -> List[ImageItem]:
101+
"""Return probe ImageItems; under tp>1 only rank 0 writes shm and broadcasts the list."""
102+
if vit_tp == 1:
103+
return self._build_worst_case_image_items(batch_size, max_image_pixels, max_image_token_count, dp_rank_id)
104+
105+
payload: List[ImageItem] = [None]
106+
if tp_rank_id == 0:
107+
payload[0] = self._build_worst_case_image_items(
108+
batch_size, max_image_pixels, max_image_token_count, dp_rank_id
109+
)
110+
dist.broadcast_object_list(payload, src=0, group=gloo_group)
111+
return payload[0]
112+
113+
def _build_worst_case_image_items(
114+
self, batch_size: int, max_image_pixels: int, max_image_token_count: int, dp_rank_id: int
115+
) -> List[ImageItem]:
116+
width, height = self._worst_case_image_size(max_image_pixels)
117+
items = []
118+
for batch_id in range(batch_size):
119+
# Alternate solid black/white so each batch slot gets a distinct JPEG payload.
120+
color = (255, 255, 255) if batch_id % 2 else (0, 0, 0)
121+
image_bytes = self._gen_rgb_jpeg_bytes(width, height, color=color)
122+
item = ImageItem(type="base64", data="")
123+
item.uuid = f"{get_unique_server_name()}_vision_peak_hold_dp{dp_rank_id}_{batch_id}"
124+
item.image_w = width
125+
item.image_h = height
126+
# InternVL encode() reads image_patch_max_num from extra_params (normally set by
127+
# InternvlTokenizer.init_imageitem_extral_params). Use MAX_PATH_NUM for worst-case tile count.
128+
if hasattr(self.model, "MAX_PATH_NUM"):
129+
item.extra_params["image_patch_max_num"] = int(self.model.MAX_PATH_NUM)
130+
131+
create_shm(get_shm_name_data(item.uuid), image_bytes)
132+
items.append(item)
133+
return items
134+
135+
@staticmethod
136+
def _free_image_items(items: List[ImageItem]) -> None:
137+
for item in items:
138+
try:
139+
free_shm(get_shm_name_data(item.uuid))
140+
except FileNotFoundError:
141+
pass

0 commit comments

Comments
 (0)