| """Helpers for LLaVA-OV teacher batches (per-sample vision tensors).""" |
| from __future__ import annotations |
|
|
| import os |
| from typing import Any, Optional |
|
|
| import torch |
| import torch.nn.functional as F |
| from PIL import Image |
|
|
| from opsd_utils import debug_log as opsd_debug |
| from opsd_utils.deepspeed_utils import should_colocate_teacher_with_student |
|
|
| |
| TEACHER_IMAGE_STACKED_KEYS = frozenset({"teacher_pixel_values", "teacher_image_sizes"}) |
| TEACHER_VISION_LIST_KEYS = frozenset({"teacher_pixel_values_list", "teacher_image_sizes_list"}) |
|
|
|
|
| def image_token_id(processor) -> int: |
| tok = processor.tokenizer |
| if getattr(tok, "image_token_id", None) is not None: |
| return int(tok.image_token_id) |
| if hasattr(processor, "image_token_id"): |
| return int(processor.image_token_id) |
| convert = getattr(tok, "convert_tokens_to_ids", None) |
| if convert is not None: |
| return int(convert(getattr(processor, "image_token", "<image>"))) |
| return 151646 |
|
|
|
|
| def count_image_tokens(input_ids: torch.Tensor, processor) -> int: |
| img_id = image_token_id(processor) |
| return int((input_ids == img_id).sum().item()) |
|
|
|
|
| def batch_size_from_tensor_dict(tensor_dict: dict[str, Any]) -> int: |
| for key in ("prompt_ids", "teacher_prompt_ids", "completion_ids"): |
| tensor = tensor_dict.get(key) |
| if tensor is not None: |
| return int(tensor.shape[0]) |
| for value in tensor_dict.values(): |
| if isinstance(value, torch.Tensor): |
| return int(value.shape[0]) |
| if isinstance(value, list) and value: |
| return len(value) |
| return 0 |
|
|
|
|
| def teacher_image_counts_from_dict( |
| tensor_dict: dict[str, Any], |
| batch_size: int, |
| ) -> list[int]: |
| counts = tensor_dict.get("teacher_num_images") |
| if counts is None: |
| return [1] * batch_size |
| if isinstance(counts, torch.Tensor): |
| return [int(max(1, c)) for c in counts.detach().cpu().tolist()] |
| return [int(max(1, c)) for c in counts] |
|
|
|
|
| def image_offsets(counts: list[int]) -> list[int]: |
| offsets = [0] |
| for c in counts: |
| offsets.append(offsets[-1] + c) |
| return offsets |
|
|
|
|
| def split_tensor_dict_for_opsd( |
| tensor_dict: dict[str, Any], |
| num_chunks: int, |
| ) -> list[dict[str, Any]]: |
| """ |
| Split batch tensors for gradient accumulation. |
| |
| Teacher vision is stored per-sample in *_list keys to preserve |
| input_ids <-> pixel_values alignment (variable patch counts). |
| """ |
| batch_size = batch_size_from_tensor_dict(tensor_dict) |
| if batch_size == 0 or num_chunks <= 0: |
| return [dict(tensor_dict)] |
| if batch_size % num_chunks != 0: |
| opsd_debug.log( |
| "teacher_batching", |
| "split_tensor_dict_for_opsd uneven batch", |
| batch_size=batch_size, |
| num_chunks=num_chunks, |
| ) |
| chunk_batch = max(1, batch_size // num_chunks) |
| img_counts = teacher_image_counts_from_dict(tensor_dict, batch_size) |
| img_offs = image_offsets(img_counts) |
|
|
| chunks: list[dict[str, Any]] = [] |
| for i in range(num_chunks): |
| b0 = i * chunk_batch |
| b1 = min((i + 1) * chunk_batch, batch_size) |
| if b0 >= batch_size: |
| break |
| img0, img1 = img_offs[b0], img_offs[b1] |
| chunk: dict[str, Any] = {} |
| for key, value in tensor_dict.items(): |
| if value is None: |
| chunk[key] = None |
| elif key in TEACHER_VISION_LIST_KEYS: |
| chunk[key] = value[b0:b1] |
| elif key in TEACHER_IMAGE_STACKED_KEYS: |
| chunk[key] = value[img0:img1] |
| elif isinstance(value, torch.Tensor): |
| chunk[key] = value[b0:b1] |
| else: |
| chunk[key] = value |
| chunks.append(chunk) |
|
|
| opsd_debug.log( |
| "teacher_batching", |
| "split_tensor_dict_for_opsd", |
| batch_size=batch_size, |
| num_chunks=num_chunks, |
| chunk_batch=chunk_batch, |
| teacher_num_images=img_counts, |
| image_offsets=img_offs, |
| uses_vision_lists=tensor_dict.get("teacher_pixel_values_list") is not None, |
| output_chunks=len(chunks), |
| ) |
| return chunks |
|
|
|
|
| def stack_teacher_processor_batches( |
| processor, |
| per_sample_batches: list[dict[str, Any]], |
| ) -> dict[str, Any]: |
| """Pad input_ids per sample; keep pixel_values as per-sample list (no cross-sample cat).""" |
| if not per_sample_batches: |
| return {} |
|
|
| pad_id = processor.tokenizer.pad_token_id |
| max_len = max(int(b["input_ids"].shape[1]) for b in per_sample_batches) |
|
|
| input_ids_list: list[torch.Tensor] = [] |
| attn_list: list[torch.Tensor] = [] |
| pixel_list: list[torch.Tensor] = [] |
| size_list: list[torch.Tensor] = [] |
| batch_num_images: list[int] = [] |
| image_token_counts: list[int] = [] |
|
|
| for batch in per_sample_batches: |
| ids = batch["input_ids"] |
| attn = batch["attention_mask"] |
| pad_len = max_len - ids.shape[1] |
| if pad_len > 0: |
| ids = F.pad(ids, (0, pad_len), value=pad_id) |
| attn = F.pad(attn, (0, pad_len), value=0) |
| input_ids_list.append(ids) |
| attn_list.append(attn) |
| image_token_counts.append(count_image_tokens(ids, processor)) |
|
|
| if "pixel_values" in batch: |
| pv = batch["pixel_values"] |
| pixel_list.append(pv) |
| batch_num_images.append(int(pv.shape[0])) |
| else: |
| batch_num_images.append(0) |
| if "image_sizes" in batch: |
| size_list.append(batch["image_sizes"]) |
|
|
| patch_counts = [int(pv.shape[1]) for pv in pixel_list] if pixel_list else [] |
| if len(set(patch_counts)) > 1: |
| opsd_debug.log( |
| "teacher_batching", |
| "per-sample teacher patch counts (kept aligned via list storage)", |
| patch_counts_per_sample=patch_counts, |
| num_samples=len(per_sample_batches), |
| ) |
|
|
| out: dict[str, Any] = { |
| "input_ids": torch.cat(input_ids_list, dim=0), |
| "attention_mask": torch.cat(attn_list, dim=0), |
| "batch_num_images": batch_num_images, |
| "pixel_values_list": pixel_list, |
| "image_sizes_list": size_list, |
| "image_token_counts": image_token_counts, |
| } |
| return out |
|
|
|
|
| def _messages_for_teacher(teacher_text: str, images: list[Image.Image]) -> list[dict]: |
| """Build chat messages with PIL images embedded (single processor tokenize path).""" |
| content: list[dict] = [] |
| for img in images: |
| content.append({"type": "image", "image": img}) |
| content.append({"type": "text", "text": teacher_text}) |
| return [{"role": "user", "content": content}] |
|
|
|
|
| def process_teacher_sample(processor, teacher_text: str, images: list[Any]) -> dict[str, Any]: |
| """Tokenize one teacher sample via processor.apply_chat_template(tokenize=True).""" |
| pil_images = [img for img in images if isinstance(img, Image.Image)] |
| messages = _messages_for_teacher(teacher_text, pil_images) |
| batch = processor.apply_chat_template( |
| messages, |
| add_generation_prompt=True, |
| tokenize=True, |
| return_dict=True, |
| return_tensors="pt", |
| padding=True, |
| ) |
| n_img_tok = count_image_tokens(batch["input_ids"], processor) |
| opsd_debug.log( |
| "teacher_batching", |
| "process_teacher_sample", |
| num_images=len(pil_images), |
| input_ids_shape=tuple(batch["input_ids"].shape), |
| pixel_values_shape=tuple(batch["pixel_values"].shape) if "pixel_values" in batch else None, |
| image_token_count=n_img_tok, |
| ) |
| return batch |
|
|
|
|
| def _unwrap_model(model): |
| if hasattr(model, "module"): |
| return model.module |
| return model |
|
|
|
|
| def resolve_teacher_device_map( |
| device_map: Optional[str], |
| *, |
| local_rank: int, |
| num_gpus: int, |
| ) -> str: |
| """ |
| Place frozen teacher relative to this rank's trainable student. |
| |
| Modes: |
| - ``same`` / ``colocate`` / ``local``: teacher on ``cuda:{local_rank}`` (use with DeepSpeed ZeRO). |
| - ``auto`` (default DDP): complementary GPU when ``num_gpus >= 2``. |
| - ``auto`` + DeepSpeed Accelerate config: colocate on ``cuda:{local_rank}``. |
| - explicit ``cuda:N``: honored unless it collides with the student device. |
| """ |
| student_dev = f"cuda:{local_rank}" |
| raw = (device_map or os.environ.get("DYME_TEACHER_DEVICE_MAP", "")).strip() |
|
|
| if raw.lower() in ("same", "colocate", "local"): |
| return student_dev |
|
|
| if should_colocate_teacher_with_student(raw): |
| return student_dev |
|
|
| if raw.lower() in ("", "auto", "complement", "opposite"): |
| if num_gpus >= 2: |
| return f"cuda:{(local_rank + 1) % num_gpus}" |
| return student_dev |
|
|
| if raw == student_dev and num_gpus >= 2: |
| resolved = f"cuda:{(local_rank + 1) % num_gpus}" |
| opsd_debug.log( |
| "teacher_placement", |
| "teacher device collides with student; using complement GPU", |
| requested=raw, |
| local_rank=local_rank, |
| resolved=resolved, |
| ) |
| return resolved |
| return raw |
|
|
|
|
| def log_teacher_placement( |
| *, |
| local_rank: int, |
| num_gpus: int, |
| teacher_path: str, |
| resolved_device: str, |
| requested_map: Optional[str], |
| ) -> None: |
| student_dev = f"cuda:{local_rank}" |
| req = requested_map or os.environ.get("DYME_TEACHER_DEVICE_MAP", "") or "auto" |
| print( |
| f"[DyME] rank={local_rank}/{num_gpus}: frozen teacher " |
| f"{teacher_path} on {resolved_device} " |
| f"(student DDP device {student_dev}, requested_map={req!r})", |
| flush=True, |
| ) |
|
|
|
|
| def _as_torch_device(dev) -> Optional[torch.device]: |
| return dev if isinstance(dev, torch.device) else None |
|
|
|
|
| def model_inference_device(model) -> torch.device: |
| """Device for teacher/student forward inputs (embeddings or vision tower).""" |
| inner = _unwrap_model(model) |
| get_emb = getattr(inner, "get_input_embeddings", None) |
| if callable(get_emb): |
| emb = get_emb() |
| if emb is not None and hasattr(emb, "weight"): |
| dev = _as_torch_device(emb.weight.device) |
| if dev is not None: |
| return dev |
| core = getattr(inner, "model", inner) |
| tower = getattr(core, "vision_tower", None) |
| if tower is not None: |
| params_fn = getattr(tower, "parameters", None) |
| if callable(params_fn): |
| for p in params_fn(): |
| dev = _as_torch_device(p.device) |
| if dev is not None and dev.type != "meta": |
| return dev |
| params_fn = getattr(inner, "parameters", None) |
| if callable(params_fn): |
| for p in params_fn(): |
| dev = _as_torch_device(p.device) |
| if dev is not None and dev.type != "meta": |
| return dev |
| return torch.device("cpu") |
|
|
|
|
| def model_inference_dtype(model) -> torch.dtype: |
| inner = _unwrap_model(model) |
| params_fn = getattr(inner, "parameters", None) |
| if callable(params_fn): |
| for p in params_fn(): |
| dev = _as_torch_device(p.device) |
| if dev is not None and dev.type != "meta": |
| return p.dtype |
| return torch.bfloat16 |
|
|
|
|
| def move_pixel_values_to_model_device(model, pixel_values): |
| """Align vision tensor device/dtype with the model (cross-model OPD safety).""" |
| if pixel_values is None or not isinstance(pixel_values, torch.Tensor): |
| return pixel_values |
| device = model_inference_device(model) |
| dtype = model_inference_dtype(model) |
| return pixel_values.to(device=device, dtype=dtype) |
|
|
|
|
| def move_batch_num_images_to_model_device(model, batch_num_images: Optional[torch.Tensor]): |
| if batch_num_images is None or not isinstance(batch_num_images, torch.Tensor): |
| return batch_num_images |
| return batch_num_images.to(device=model_inference_device(model)) |
|
|
|
|
| def as_batch_num_images_tensor( |
| num_images: int | None, |
| pixel_values: Optional[torch.Tensor], |
| batch_rows: int = 1, |
| ) -> Optional[torch.Tensor]: |
| """Build batch_num_images for LLaVA-OV (per-sample image count in each batch row).""" |
| if pixel_values is None or num_images is None: |
| return None |
| n = int(max(1, num_images)) |
| device = pixel_values.device |
| return torch.tensor([n] * batch_rows, device=device, dtype=torch.long) |
|
|
|
|
| def student_batch_num_images_tensor( |
| pixel_values: Optional[torch.Tensor], |
| batch_rows: int, |
| ) -> Optional[torch.Tensor]: |
| """ |
| Infer batch_num_images for student / collator-batched pixel_values. |
| |
| HF processor output uses dim0 as batch size (one chart image per row); do not |
| treat dim0 as the image count when it equals batch_rows. |
| """ |
| if pixel_values is None or batch_rows <= 0: |
| return None |
| device = pixel_values.device |
| pv_batch = int(pixel_values.shape[0]) |
| if pixel_values.ndim >= 4 and pv_batch == batch_rows: |
| return torch.ones(batch_rows, device=device, dtype=torch.long) |
| n_img = int(max(1, pv_batch)) |
| return torch.tensor([n_img] * batch_rows, device=device, dtype=torch.long) |
|
|
|
|
| def _image_feature_row_count(result) -> int: |
| """Total vision placeholder rows from LLaVA-OV get_image_features return value.""" |
| if hasattr(result, "pooler_output") and result.pooler_output is not None: |
| packed = result.pooler_output |
| else: |
| packed = result |
| if isinstance(packed, (list, tuple)): |
| if not packed: |
| return 0 |
| return int(torch.cat(packed, dim=0).shape[0]) |
| if isinstance(packed, torch.Tensor): |
| return int(packed.shape[0]) |
| return 0 |
|
|
|
|
| @torch.no_grad() |
| def expected_image_feature_count( |
| model, |
| pixel_values, |
| image_sizes, |
| batch_num_images: Optional[torch.Tensor] = None, |
| ) -> int: |
| """Vision feature rows after LLaVA-OV pack (matches model forward placeholder count).""" |
| if pixel_values is None: |
| return 0 |
| pixel_values = move_pixel_values_to_model_device(model, pixel_values) |
| batch_num_images = move_batch_num_images_to_model_device(model, batch_num_images) |
| inner = _unwrap_model(model) |
| if not hasattr(inner, "model"): |
| return 0 |
| core = inner.model |
| vision_feature_layer = getattr(core.config, "vision_feature_layer", -1) |
| vision_feature_select_strategy = getattr(core.config, "vision_feature_select_strategy", "full") |
| vision_aspect_ratio = getattr(core.config, "vision_aspect_ratio", "anyres_max_9") |
| |
| |
| result = core.get_image_features( |
| pixel_values, |
| image_sizes, |
| vision_feature_layer=vision_feature_layer, |
| vision_feature_select_strategy=vision_feature_select_strategy, |
| vision_aspect_ratio=vision_aspect_ratio, |
| batch_num_images=batch_num_images, |
| ) |
| return _image_feature_row_count(result) |
|
|
|
|
| def truncate_image_tokens( |
| input_ids: torch.Tensor, |
| attention_mask: torch.Tensor, |
| image_token_id_value: int, |
| max_image_tokens: int, |
| pad_token_id: int, |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| """Keep the first max_image_tokens image placeholders; drop extras (anyres_max mismatch fix).""" |
| if max_image_tokens < 0: |
| return input_ids, attention_mask |
| trimmed_rows: list[list[int]] = [] |
| trimmed_masks: list[list[int]] = [] |
| for row_ids, row_mask in zip(input_ids, attention_mask): |
| valid = [(int(t), int(m)) for t, m in zip(row_ids.tolist(), row_mask.tolist()) if m] |
| if not valid: |
| trimmed_rows.append(row_ids.tolist()) |
| trimmed_masks.append(row_mask.tolist()) |
| continue |
| new_ids: list[int] = [] |
| kept_img = 0 |
| for tok, _ in valid: |
| if tok == image_token_id_value: |
| if kept_img < max_image_tokens: |
| new_ids.append(tok) |
| kept_img += 1 |
| else: |
| new_ids.append(tok) |
| trimmed_rows.append(new_ids) |
| trimmed_masks.append([1] * len(new_ids)) |
| max_len = max(len(r) for r in trimmed_rows) |
| out_ids = [] |
| out_mask = [] |
| for row, mask in zip(trimmed_rows, trimmed_masks): |
| pad_len = max_len - len(row) |
| out_ids.append(row + [pad_token_id] * pad_len) |
| out_mask.append(mask + [0] * pad_len) |
| return ( |
| torch.tensor(out_ids, dtype=input_ids.dtype, device=input_ids.device), |
| torch.tensor(out_mask, dtype=attention_mask.dtype, device=attention_mask.device), |
| ) |
|
|
|
|
| def _slice_row_image_sizes(image_sizes, index: int): |
| if image_sizes is None: |
| return None |
| if isinstance(image_sizes, torch.Tensor): |
| if image_sizes.dim() == 0: |
| return image_sizes |
| if image_sizes.shape[0] > index: |
| return image_sizes[index : index + 1] |
| return image_sizes |
| if isinstance(image_sizes, (list, tuple)) and len(image_sizes) > index: |
| return image_sizes[index] |
| return image_sizes |
|
|
|
|
| def _row_batch_num_images( |
| batch_num_images: Optional[torch.Tensor], |
| index: int, |
| pixel_values_row, |
| ) -> Optional[torch.Tensor]: |
| if batch_num_images is None: |
| return student_batch_num_images_tensor(pixel_values_row, 1) |
| if batch_num_images.numel() == 1: |
| return batch_num_images |
| if batch_num_images.shape[0] > index: |
| return batch_num_images[index : index + 1] |
| return batch_num_images |
|
|
|
|
| def _align_prompt_image_tokens_one_row( |
| model, |
| processor, |
| input_ids: torch.Tensor, |
| attention_mask: torch.Tensor, |
| pixel_values, |
| image_sizes, |
| batch_num_images: Optional[torch.Tensor] = None, |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| """Align image placeholders for a single batch row (shape [1, L]).""" |
| if pixel_values is None: |
| return input_ids, attention_mask |
| img_id = image_token_id(processor) |
| n_tokens = int((input_ids == img_id).sum().item()) |
| n_features = expected_image_feature_count( |
| model, pixel_values, image_sizes, batch_num_images=batch_num_images |
| ) |
| if n_features <= 0 or n_tokens == n_features: |
| return input_ids, attention_mask |
| opsd_debug.log( |
| "teacher_batching", |
| "align image tokens to vision features (one row)", |
| image_tokens=n_tokens, |
| image_features=n_features, |
| delta=n_tokens - n_features, |
| batch_num_images=batch_num_images.detach().cpu().tolist() if batch_num_images is not None else None, |
| ) |
| pad_id = int(processor.tokenizer.pad_token_id) |
| return truncate_image_tokens(input_ids, attention_mask, img_id, n_features, pad_id) |
|
|
|
|
| def _pad_aligned_batch_rows( |
| ids_rows: list[torch.Tensor], |
| mask_rows: list[torch.Tensor], |
| pad_id: int, |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| max_len = max(int(row.shape[1]) for row in ids_rows) |
| out_ids: list[torch.Tensor] = [] |
| out_masks: list[torch.Tensor] = [] |
| for ids, mask in zip(ids_rows, mask_rows): |
| pad_len = max_len - ids.shape[1] |
| if pad_len > 0: |
| ids = F.pad(ids, (0, pad_len), value=pad_id) |
| mask = F.pad(mask, (0, pad_len), value=0) |
| out_ids.append(ids) |
| out_masks.append(mask) |
| return torch.cat(out_ids, dim=0), torch.cat(out_masks, dim=0) |
|
|
|
|
| def align_teacher_prompt_image_tokens( |
| model, |
| processor, |
| input_ids: torch.Tensor, |
| attention_mask: torch.Tensor, |
| pixel_values, |
| image_sizes, |
| batch_num_images: Optional[torch.Tensor] = None, |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| """Sync image placeholder count in input_ids to vision feature count (per sample).""" |
| if pixel_values is None: |
| return input_ids, attention_mask |
|
|
| batch_rows = int(input_ids.shape[0]) |
| if batch_rows <= 1: |
| return _align_prompt_image_tokens_one_row( |
| model, |
| processor, |
| input_ids, |
| attention_mask, |
| pixel_values, |
| image_sizes, |
| batch_num_images=batch_num_images, |
| ) |
|
|
| |
| pad_id = int(processor.tokenizer.pad_token_id) |
| ids_rows: list[torch.Tensor] = [] |
| mask_rows: list[torch.Tensor] = [] |
| for row in range(batch_rows): |
| row_pv = pixel_values[row : row + 1] |
| row_sizes = _slice_row_image_sizes(image_sizes, row) |
| row_bn = _row_batch_num_images(batch_num_images, row, row_pv) |
| row_ids, row_mask = _align_prompt_image_tokens_one_row( |
| model, |
| processor, |
| input_ids[row : row + 1], |
| attention_mask[row : row + 1], |
| row_pv, |
| row_sizes, |
| batch_num_images=row_bn, |
| ) |
| ids_rows.append(row_ids) |
| mask_rows.append(row_mask) |
| return _pad_aligned_batch_rows(ids_rows, mask_rows, pad_id) |
|
|
|
|
| def get_teacher_vision_for_sample( |
| inputs: dict[str, Any], |
| local: int, |
| num_images_per_sample: Optional[list[int]] = None, |
| ) -> tuple[Optional[torch.Tensor], Optional[torch.Tensor]]: |
| """Return (pixel_values, image_sizes) for one batch row, aligned with teacher_prompt_ids.""" |
| pv_list = inputs.get("teacher_pixel_values_list") |
| if pv_list is not None: |
| if local >= len(pv_list): |
| return None, None |
| t_pixel = pv_list[local] |
| sizes_list = inputs.get("teacher_image_sizes_list") or [] |
| t_sizes = sizes_list[local] if local < len(sizes_list) else None |
| return t_pixel, t_sizes |
|
|
| |
| from opsd_utils.opsd_loss import slice_teacher_vision_inputs |
|
|
| batch_size = inputs["prompt_ids"].shape[0] |
| counts = num_images_per_sample or teacher_image_counts_from_dict(inputs, batch_size) |
| return slice_teacher_vision_inputs( |
| inputs.get("teacher_pixel_values"), |
| inputs.get("teacher_image_sizes"), |
| local, |
| counts, |
| ) |
|
|