agentic-rl-main / opsd_utils /teacher_batching.py
Jack04810's picture
Add files using upload-large-folder tool
534c64f verified
Raw
History Blame Contribute Delete
21.8 kB
"""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
# Legacy stacked tensors (dim0 = total images). Prefer *_list keys.
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")
# transformers 4.57.x: returns list[Tensor] (already packed per image); no return_dict kwarg.
# transformers 5.x: may return BaseModelOutputWithPooling with pooler_output.
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,
)
# Batched prompts must be aligned per row; global token/feature totals hide per-row mismatch.
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
# Legacy stacked layout
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,
)