Buckets:
| """Visual-OPSD Training — on-policy self-distillation for unified multimodal models. | |
| Each step (per micro-batch, batch-size=1): | |
| 1. Fetch one raw sample (image + question + privileged VT trace). | |
| 2. Sample a full completion from the CURRENT student weights | |
| (``OnPolicySampler`` + FSDP.summon_full_params). | |
| 3. Build two packed batches sharing that SAME completion: | |
| student : [image, question, completion] | |
| teacher : [image, question, <reference_intro>, (VT_image_i)+, | |
| <transition>, completion] | |
| The teacher's privileged channel is STRICTLY VISUAL-ONLY: only the | |
| intermediate VT images appear. Text thoughts and the ground-truth | |
| answer are deliberately omitted so the teacher--student information | |
| gap isolates the visual generation pathway. | |
| 4. Forward student (grad) and teacher (no_grad). | |
| 5. Loss = ce_weight * CE(student, completion) | |
| + jsd_weight * generalizedJSD(student_completion_logits, | |
| teacher_completion_logits) | |
| The paper uses pure JSD: ce_weight=0, jsd_weight=1. | |
| 6. Backward, optimizer step, EMA update. | |
| The JSD is applied over the ENTIRE sampled completion span — not just the | |
| final answer — because that is the set of positions where the student was | |
| on-policy. Since both packs end with the identical completion block, the | |
| two logits tensors line up 1-to-1 without any extra masking. | |
| Usage | |
| ----- | |
| torchrun --nproc_per_node=8 scripts/visual_opsd/train_visual_opsd.py \ | |
| --model_path models/ThinkMorph-7B \ | |
| --dataset_config_file ./data/configs/visual_opsd.yaml \ | |
| --teacher_mode ema --ema_decay 0.995 \ | |
| --jsd_beta 0.5 --jsd_temperature 1.0 --jsd_top_k 256 \ | |
| --jsd_token_clip 0.05 --jsd_weight 1.0 --ce_weight 1.0 \ | |
| --total_steps 8000 --lr 1e-5 | |
| """ | |
| from __future__ import annotations | |
| import functools | |
| import os | |
| import sys | |
| from datetime import timedelta | |
| from contextlib import nullcontext | |
| from copy import deepcopy | |
| from dataclasses import dataclass, field | |
| from time import time | |
| from typing import Any, Dict, Optional | |
| import torch | |
| import torch.distributed as dist | |
| from transformers.utils.dummy_pt_objects import FalconForQuestionAnswering | |
| import wandb | |
| import yaml | |
| from torch.distributed.algorithms._checkpoint.checkpoint_wrapper import ( | |
| CheckpointImpl, | |
| apply_activation_checkpointing, | |
| checkpoint_wrapper, | |
| ) | |
| from torch.utils.data import DataLoader | |
| from tqdm import tqdm | |
| from transformers import HfArgumentParser, set_seed | |
| from transformers.optimization import ( | |
| get_constant_schedule_with_warmup, | |
| get_cosine_with_min_lr_schedule_with_warmup, | |
| ) | |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) | |
| from data.data_utils import add_special_tokens | |
| from data.opsd_pack_builder import ( | |
| OPSDPackBuilder, | |
| PackBuilderConfig, | |
| TEACHER_REFERENCE_INTRO, | |
| TEACHER_TRANSITION, | |
| VLM_THINK_SYSTEM_PROMPT, | |
| packed_batch_to_device, | |
| ) | |
| from data.opsd_paired_dataset import build_opsd_raw_dataset | |
| from modeling.bagel import ( | |
| Bagel, | |
| BagelConfig, | |
| Qwen2Config, | |
| Qwen2ForCausalLM, | |
| SiglipVisionConfig, | |
| SiglipVisionModel, | |
| ) | |
| from modeling.qwen2 import Qwen2Tokenizer | |
| from scripts.visual_opsd.on_policy_sampler import ( | |
| OnPolicySampler, | |
| SamplingConfig, | |
| broadcast_raw, | |
| ) | |
| from scripts.visual_opsd.opsd_loss import compute_student_kd_loss | |
| from train.fsdp_utils import ( | |
| FSDPCheckpoint, | |
| FSDPConfig, | |
| fsdp_ema_setup, | |
| fsdp_ema_update, | |
| fsdp_wrapper, | |
| grad_checkpoint_check_fn, | |
| ) | |
| from train.train_utils import create_logger, get_latest_ckpt | |
| # --------------------------------------------------------------------- # | |
| # Arguments | |
| # --------------------------------------------------------------------- # | |
| class ModelArguments: | |
| model_path: str = "models/ThinkMorph-7B" | |
| llm_qk_norm: bool = True | |
| tie_word_embeddings: bool = False | |
| layer_module: str = "Qwen2MoTDecoderLayer" | |
| max_latent_size: int = 64 | |
| latent_patch_size: int = 2 | |
| vit_patch_size: int = 14 | |
| vit_max_num_patch_per_side: int = 70 | |
| connector_act: str = "gelu_pytorch_tanh" | |
| interpolate_pos: bool = False | |
| vit_select_layer: int = -2 | |
| vit_rope: bool = False | |
| text_cond_dropout_prob: float = 0.0 | |
| vae_cond_dropout_prob: float = 0.0 | |
| vit_cond_dropout_prob: float = 0.0 | |
| class DataArguments: | |
| dataset_config_file: str = "data/configs/visual_opsd.yaml" | |
| prefetch_factor: int = 2 | |
| num_workers: int = 4 | |
| data_seed: int = 42 | |
| max_reference_rounds: int = 8 | |
| class TrainingArguments: | |
| visual_gen: bool = False | |
| visual_und: bool = True | |
| results_dir: str = "results/visual_opsd" | |
| checkpoint_dir: str = "results/visual_opsd/checkpoints" | |
| wandb_project: str = "Visual-OPSD" | |
| wandb_name: str = "visual-opsd-run" | |
| wandb_runid: str = "0" | |
| wandb_resume: str = "allow" | |
| wandb_offline: bool = False | |
| global_seed: int = 4396 | |
| auto_resume: bool = False | |
| resume_from: Optional[str] = None | |
| resume_model_only: bool = True | |
| finetune_from_ema: bool = True | |
| finetune_from_hf: bool = True | |
| log_every: int = 10 | |
| save_every: int = 1000 | |
| total_steps: int = 8000 | |
| warmup_steps: int = 200 | |
| lr_scheduler: str = "cosine" | |
| lr: float = 1e-5 | |
| min_lr: float = 1e-7 | |
| beta1: float = 0.9 | |
| beta2: float = 0.95 | |
| eps: float = 1e-15 | |
| ema: float = 0.9999 | |
| max_grad_norm: float = 1.0 | |
| mse_weight: float = 0.0 | |
| ce_weight: float = 1.0 | |
| ce_loss_reweighting: bool = False | |
| timestep_shift: float = 1.0 | |
| num_replicate: int = 1 | |
| num_shard: int = 4 | |
| sharding_strategy: str = "HYBRID_SHARD" | |
| backward_prefetch: str = "BACKWARD_PRE" | |
| cpu_offload: bool = False | |
| freeze_llm: bool = False | |
| freeze_vit: bool = False | |
| freeze_vae: bool = True | |
| freeze_und: bool = False | |
| copy_init_moe: bool = True | |
| use_flex: bool = False | |
| gradient_accumulation_steps: int = 1 | |
| # --- Visual-OPSD: teacher --- | |
| teacher_mode: str = field( | |
| default="ema", | |
| metadata={"help": "self | ema | fixed"}, | |
| ) | |
| ema_decay: float = field( | |
| default=0.995, | |
| metadata={ | |
| "help": "EMA decay for the teacher model when teacher_mode=ema. " | |
| "OPSD uses 0.9-0.99 (much more lag than optimization EMA)." | |
| }, | |
| ) | |
| use_ema_weight_tracker: bool = field( | |
| default=False, | |
| metadata={ | |
| "help": "When teacher_mode != 'ema', also maintain an EMA-of-" | |
| "weights tracker for eval-time stability. Costs ~one full " | |
| "model shard of persistent GPU memory per rank. When " | |
| "teacher_mode == 'ema' the EMA model IS the teacher and is " | |
| "always kept regardless of this flag." | |
| }, | |
| ) | |
| teacher_cpu_offload: bool = field( | |
| default=True, | |
| metadata={ | |
| "help": "If True and teacher_mode='fixed', wrap fixed_teacher " | |
| "with FSDP CPU offload. Sharded params live on CPU; PCIe is " | |
| "paid during the single no-grad teacher forward per micro-step " | |
| "in exchange for ~one model shard of persistent GPU memory. " | |
| "Required on 80 GB GPUs with 3+ FSDP models and " | |
| "summon_full_params." | |
| }, | |
| ) | |
| # --- Visual-OPSD: on-policy sampling --- | |
| sample_max_new_tokens: int = field( | |
| default=512, | |
| metadata={ | |
| "help": "TOTAL max-tokens budget for the sampled completion " | |
| "across all skip-image rounds (not per round). Set to a " | |
| "value small enough that the completion, pack, and summon " | |
| "buffer fit within the per-rank GPU budget." | |
| }, | |
| ) | |
| sample_temperature: float = 1.0 | |
| sample_do_sample: bool = True | |
| sample_max_image_skips: int = field( | |
| default=1, | |
| metadata={ | |
| "help": "Max number of <image_end> injections to skip image " | |
| "generation and keep sampling text, for overfit interleaved " | |
| "policies. 0 disables the fix and preserves the legacy " | |
| "single-round behaviour. See " | |
| "scripts/visual_opsd/on_policy_sampler.py." | |
| }, | |
| ) | |
| sample_on_rank0_only: bool = field( | |
| default=False, | |
| metadata={ | |
| "help": "If True, only global rank 0 materializes the full " | |
| "unsharded student during FSDP.summon_full_params for " | |
| "on-policy sampling; the completion is then broadcast to " | |
| "every other rank, and to preserve (prompt, completion) " | |
| "consistency rank 0's raw batch is ALSO broadcast to every " | |
| "other rank before sampling. Saves ~one full-model BF16 " | |
| "copy of GPU memory (~29 GB for a 14.5 B MoT backbone) on " | |
| "every non-rank-0 rank, at the cost of reducing the per-" | |
| "optimizer-step effective batch size by world_size " | |
| "(every rank trains on the same sample). Enable as a last " | |
| "resort when CPU-offloading the fixed_teacher and the " | |
| "in-place BF16 summon patch are not enough to fit the " | |
| "training step within the per-GPU budget, and compensate " | |
| "for the smaller effective batch by increasing " | |
| "``gradient_accumulation_steps``." | |
| }, | |
| ) | |
| use_system_prompt: bool = True | |
| use_reference_intro: bool = True | |
| use_transition_prompt: bool = True | |
| instruction_suffix: str = ( | |
| "" # e.g. " Let's think step by step." — appended to question text | |
| ) | |
| # --- Visual-OPSD: forward memory budget --- | |
| max_forward_tokens: int = field( | |
| default=10240, | |
| metadata={ | |
| "help": "Maximum total packed-sequence length (in tokens) for any " | |
| "single forward pass. When the teacher batch (which includes " | |
| "reference thoughts, VT images, and the sampled completion) " | |
| "exceeds this limit, the completion is truncated so the " | |
| "sequence fits. The dense nested-attention mask scales as " | |
| "O(L^2) and is the dominant OOM contributor for long " | |
| "sequences; this cap keeps memory predictable." | |
| }, | |
| ) | |
| # --- Visual-OPSD: distillation loss --- | |
| jsd_weight: float = 1.0 | |
| jsd_beta: float = field( | |
| default=0.5, | |
| metadata={"help": "0=fwd-KL, 1=rev-KL, 0.5=symmetric JSD"}, | |
| ) | |
| jsd_temperature: float = 1.0 | |
| jsd_top_k: int = field( | |
| default=256, | |
| metadata={"help": "restrict JSD to teacher's top-K tokens (0=off)"}, | |
| ) | |
| jsd_token_clip: float = field( | |
| default=0.05, | |
| metadata={"help": "per-token JSD clip (OPSD default 0.05)"}, | |
| ) | |
| jsd_loss_kind: str = field( | |
| default="jsd", | |
| metadata={"help": "'jsd' or 'tinker'"}, | |
| ) | |
| noise_vt: bool = field( | |
| default=False, | |
| metadata={ | |
| "help": "Replace teacher's privileged VT (visual thought) image " | |
| "tensors with random Gaussian noise of the same shape. The " | |
| "teacher still sees the reference text thoughts and answer; only " | |
| "the interleaved images become uninformative. Used as an " | |
| "ablation to isolate the contribution of visual privileged " | |
| "information in Visual-OPSD distillation." | |
| }, | |
| ) | |
| # --------------------------------------------------------------------- # | |
| # Model builder | |
| # --------------------------------------------------------------------- # | |
| def build_bagel_model(model_args, training_args): | |
| llm_config = Qwen2Config.from_json_file( | |
| os.path.join(model_args.model_path, "llm_config.json") | |
| ) | |
| llm_config.layer_module = model_args.layer_module | |
| llm_config.qk_norm = model_args.llm_qk_norm | |
| llm_config.tie_word_embeddings = model_args.tie_word_embeddings | |
| llm_config.freeze_und = training_args.freeze_und | |
| language_model = Qwen2ForCausalLM(llm_config) | |
| if training_args.copy_init_moe: | |
| language_model.init_moe() | |
| vit_config = SiglipVisionConfig.from_json_file( | |
| os.path.join(model_args.model_path, "vit_config.json") | |
| ) | |
| vit_config.num_hidden_layers = ( | |
| vit_config.num_hidden_layers + 1 + model_args.vit_select_layer | |
| ) | |
| vit_config.rope = model_args.vit_rope | |
| vit_model = SiglipVisionModel(vit_config) | |
| config = BagelConfig( | |
| visual_gen=False, | |
| visual_und=training_args.visual_und, | |
| llm_config=llm_config, | |
| vit_config=vit_config if training_args.visual_und else None, | |
| vae_config=None, | |
| latent_patch_size=model_args.latent_patch_size, | |
| max_latent_size=model_args.max_latent_size, | |
| vit_max_num_patch_per_side=model_args.vit_max_num_patch_per_side, | |
| connector_act=model_args.connector_act, | |
| interpolate_pos=model_args.interpolate_pos, | |
| timestep_shift=training_args.timestep_shift, | |
| ) | |
| model = Bagel(language_model, vit_model if training_args.visual_und else None, config) | |
| if training_args.visual_und: | |
| model.vit_model.vision_model.embeddings.convert_conv2d_to_linear(vit_config) | |
| return model, llm_config, vit_config | |
| # --------------------------------------------------------------------- # | |
| # Raw sample iterator helper | |
| # --------------------------------------------------------------------- # | |
| def _raw_identity_collate(batch): | |
| """IterableDataset loader emits single-sample lists; unwrap to a dict.""" | |
| assert len(batch) == 1 | |
| return batch[0] | |
| def _move_image_tensor(sample: Dict[str, Any], device) -> Dict[str, Any]: | |
| """Move ``problem_image_tensor`` (and VT tensors) to ``device`` eagerly. | |
| We keep text ids / lists on CPU — they are tiny and are consumed by | |
| Python-side builders. | |
| """ | |
| if "problem_image_tensor" in sample and torch.is_tensor(sample["problem_image_tensor"]): | |
| sample["problem_image_tensor"] = sample["problem_image_tensor"].to(device) | |
| vts = sample.get("reference_vt_tensors") | |
| if vts: | |
| sample["reference_vt_tensors"] = [ | |
| v.to(device) if torch.is_tensor(v) else v for v in vts | |
| ] | |
| return sample | |
| # --------------------------------------------------------------------- # | |
| # Optimizer state CPU offload for sampling memory headroom | |
| # --------------------------------------------------------------------- # | |
| def _offload_optim_to_cpu(optimizer: torch.optim.Optimizer) -> None: | |
| """Move all CUDA optimizer state tensors (AdamW m, v) to CPU. | |
| After ``optimizer.step()`` lazily creates the FP32 ``exp_avg`` and | |
| ``exp_avg_sq`` buffers, they collectively consume ~2x the sharded- | |
| parameter memory per rank. For a 14.5 B model on 8 GPUs that is | |
| roughly 14.5 GiB — enough to make ``FSDP.summon_full_params`` OOM | |
| when allocating the BF16 unshard buffer. | |
| Call this before the sampling / forward / backward phase so the GPU | |
| memory is available for the compute-intensive steps. Pair with | |
| ``_restore_optim_to_gpu`` before ``optimizer.step()``. | |
| """ | |
| for state in optimizer.state.values(): | |
| for k, v in state.items(): | |
| if isinstance(v, torch.Tensor) and v.is_cuda: | |
| state[k] = v.to("cpu", non_blocking=True) | |
| torch.cuda.synchronize() | |
| torch.cuda.empty_cache() | |
| def _restore_optim_to_gpu( | |
| optimizer: torch.optim.Optimizer, | |
| device: int | torch.device, | |
| ) -> None: | |
| """Move all CPU optimizer state tensors back to *device*. | |
| Inverse of ``_offload_optim_to_cpu``. Must be called before | |
| ``optimizer.step()`` so the state tensors reside on the same device | |
| as the parameters. | |
| """ | |
| for state in optimizer.state.values(): | |
| for k, v in state.items(): | |
| if isinstance(v, torch.Tensor) and v.device.type == "cpu": | |
| state[k] = v.to(device, non_blocking=True) | |
| torch.cuda.synchronize() | |
| # --------------------------------------------------------------------- # | |
| # Main | |
| # --------------------------------------------------------------------- # | |
| def main() -> None: | |
| assert torch.cuda.is_available() | |
| nccl_timeout = timedelta(minutes=30) | |
| dist.init_process_group("nccl", timeout=nccl_timeout) | |
| # HYBRID_SHARD creates sub-process-groups via init_device_mesh that | |
| # inherit the default 10-min NCCL timeout, not the one above. Set | |
| # the default globally so every new PG picks up 30 min as well. | |
| if hasattr(torch.distributed.distributed_c10d, "_DEFAULT_PG_NCCL_TIMEOUT"): | |
| torch.distributed.distributed_c10d._DEFAULT_PG_NCCL_TIMEOUT = nccl_timeout | |
| device = dist.get_rank() % torch.cuda.device_count() | |
| torch.cuda.set_device(device) | |
| parser = HfArgumentParser((ModelArguments, DataArguments, TrainingArguments)) | |
| model_args, data_args, training_args = parser.parse_args_into_dataclasses() | |
| if dist.get_rank() == 0: | |
| os.makedirs(training_args.results_dir, exist_ok=True) | |
| os.makedirs(training_args.checkpoint_dir, exist_ok=True) | |
| logger = create_logger(training_args.results_dir, dist.get_rank()) | |
| wandb.init( | |
| project=training_args.wandb_project, | |
| id=f"{training_args.wandb_name}-run{training_args.wandb_runid}", | |
| name=training_args.wandb_name, | |
| resume=training_args.wandb_resume, | |
| mode="offline" if training_args.wandb_offline else "online", | |
| ) | |
| wandb.config.update(training_args) | |
| else: | |
| logger = create_logger(None, dist.get_rank()) | |
| dist.barrier() | |
| logger.info( | |
| f"Visual-OPSD on-policy | teacher={training_args.teacher_mode} " | |
| f"β={training_args.jsd_beta} τ={training_args.jsd_temperature} " | |
| f"topK={training_args.jsd_top_k} clip={training_args.jsd_token_clip} " | |
| f"kind={training_args.jsd_loss_kind} " | |
| f"λ_jsd={training_args.jsd_weight} λ_ce={training_args.ce_weight} " | |
| f"sample_T={training_args.sample_temperature} " | |
| f"max_new={training_args.sample_max_new_tokens} " | |
| f"max_fwd={training_args.max_forward_tokens}" | |
| ) | |
| # -------- resume paths -------- | |
| if training_args.auto_resume: | |
| resume_from = get_latest_ckpt(training_args.checkpoint_dir) | |
| if resume_from is None: | |
| resume_from = training_args.resume_from | |
| resume_model_only = training_args.resume_model_only | |
| finetune_from_ema = ( | |
| training_args.finetune_from_ema if resume_model_only else False | |
| ) | |
| else: | |
| resume_model_only = False | |
| finetune_from_ema = False | |
| else: | |
| resume_from = training_args.resume_from | |
| resume_model_only = training_args.resume_model_only | |
| finetune_from_ema = ( | |
| training_args.finetune_from_ema if resume_model_only else False | |
| ) | |
| seed = training_args.global_seed * dist.get_world_size() + dist.get_rank() | |
| set_seed(seed) | |
| # -------- model -------- | |
| model, _, vit_config = build_bagel_model(model_args, training_args) | |
| tokenizer = Qwen2Tokenizer.from_pretrained(model_args.model_path) | |
| tokenizer, new_token_ids, num_new_tokens = add_special_tokens(tokenizer) | |
| if num_new_tokens > 0: | |
| model.language_model.resize_token_embeddings(len(tokenizer)) | |
| model.config.llm_config.vocab_size = len(tokenizer) | |
| model.language_model.config.vocab_size = len(tokenizer) | |
| if training_args.freeze_vit and training_args.visual_und: | |
| model.vit_model.eval() | |
| for p in model.vit_model.parameters(): | |
| p.requires_grad = False | |
| fsdp_config = FSDPConfig( | |
| sharding_strategy=training_args.sharding_strategy, | |
| backward_prefetch=training_args.backward_prefetch, | |
| cpu_offload=training_args.cpu_offload, | |
| num_replicate=training_args.num_replicate, | |
| num_shard=training_args.num_shard, | |
| ) | |
| # We keep an EMA model iff (a) it's the teacher (teacher_mode='ema') or | |
| # (b) the user explicitly asked for an eval-time EMA weight tracker. | |
| # In teacher_mode in {'self', 'fixed'} the EMA model would otherwise | |
| # cost ~one full model shard of persistent GPU memory per rank for no | |
| # training benefit, which is a significant fraction of the 80 GB H800 | |
| # budget once the student (params + FP32 AdamW state), fixed_teacher, | |
| # and the summon_full_params unshard buffer are accounted for. | |
| keep_ema_tracker = ( | |
| training_args.teacher_mode == "ema" | |
| or training_args.use_ema_weight_tracker | |
| ) | |
| ema_model = deepcopy(model) if keep_ema_tracker else None | |
| # Fixed teacher = a second frozen model loaded from the same init ckpt. | |
| fixed_teacher = None | |
| if training_args.teacher_mode == "fixed": | |
| fixed_teacher = deepcopy(model) | |
| if resume_from is None: | |
| resume_from = model_args.model_path | |
| model, ema_model = FSDPCheckpoint.try_load_ckpt( | |
| resume_from, logger, model, ema_model, resume_from_ema=finetune_from_ema | |
| ) | |
| if fixed_teacher is not None: | |
| # Pass ema_model=None: fixed_teacher is a single frozen model loaded | |
| # from the vanilla HF weights; we must not trigger try_load_ckpt's | |
| # ema branch (which would deepcopy fixed_teacher, re-read | |
| # model.safetensors from NAS as an "ema replica", load it into the | |
| # copy, and then discard the whole thing — pure waste of RAM, I/O, | |
| # and wall-clock time). | |
| fixed_teacher, _ = FSDPCheckpoint.try_load_ckpt( | |
| model_args.model_path, logger, fixed_teacher, None, | |
| resume_from_ema=False, | |
| ) | |
| for p in fixed_teacher.parameters(): | |
| p.requires_grad = False | |
| if ema_model is not None: | |
| ema_model = fsdp_ema_setup(ema_model, fsdp_config) | |
| if fixed_teacher is not None: | |
| # Offload fixed_teacher to CPU. It is forward-only (no gradient, | |
| # no optimizer state, no EMA update), so PCIe is only paid during | |
| # the single no_grad forward per micro-step. In exchange we free | |
| # ~one model shard of persistent GPU memory, which the student | |
| # needs for its summon_full_params unshard buffer during | |
| # on-policy sampling. | |
| teacher_offload = ( | |
| fsdp_config.cpu_offload or training_args.teacher_cpu_offload | |
| ) | |
| teacher_fsdp_config = FSDPConfig( | |
| sharding_strategy=training_args.sharding_strategy, | |
| backward_prefetch=training_args.backward_prefetch, | |
| cpu_offload=teacher_offload, | |
| num_replicate=training_args.num_replicate, | |
| num_shard=training_args.num_shard, | |
| ) | |
| fixed_teacher = fsdp_ema_setup(fixed_teacher, teacher_fsdp_config) | |
| # Defensive: FSDP's CPUOffload has a known gotcha when every | |
| # parameter of the wrapped module is frozen (requires_grad=False), | |
| # which is exactly the fixed_teacher case — the init-time | |
| # "move flat_param to CPU" step can be silently skipped, and the | |
| # sharded FP32 master weights stay resident on the GPU (for our | |
| # ~14.5 B model that is ~7 GB of persistent waste per rank). The | |
| # forward-time path in ``FlatParamHandle.pre_unshard`` moves the | |
| # flat_param to the compute device unconditionally if | |
| # ``offload_params=True``, so we can safely force a move to CPU | |
| # here after wrap and still have forward work. | |
| if teacher_offload: | |
| try: | |
| from torch.distributed.fsdp import _traversal_utils as _fsdp_traversal | |
| handles = _fsdp_traversal._get_fsdp_handles(fixed_teacher) | |
| except Exception: | |
| handles = [] | |
| moved = 0 | |
| for handle in handles: | |
| fp = getattr(handle, "flat_param", None) | |
| if fp is None or not torch.is_tensor(fp): | |
| continue | |
| if fp.device.type == "cuda": | |
| fp.data = fp.data.to("cpu") | |
| moved += 1 | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| if dist.get_rank() == 0: | |
| dev_sample = ( | |
| handles[0].flat_param.device if handles else "<no-handles>" | |
| ) | |
| logger.info( | |
| f"fixed_teacher FSDP: cpu_offload={teacher_offload}, " | |
| f"num_handles={len(handles)}, moved_to_cpu={moved}, " | |
| f"first_flat_param_device={dev_sample}" | |
| ) | |
| fsdp_model = fsdp_wrapper(model, fsdp_config) | |
| apply_activation_checkpointing( | |
| fsdp_model, | |
| checkpoint_wrapper_fn=functools.partial( | |
| checkpoint_wrapper, checkpoint_impl=CheckpointImpl.NO_REENTRANT | |
| ), | |
| check_fn=grad_checkpoint_check_fn, | |
| ) | |
| if dist.get_rank() == 0: | |
| trainable = sum(p.numel() for p in fsdp_model.parameters() if p.requires_grad) | |
| total = sum(p.numel() for p in fsdp_model.parameters()) | |
| logger.info(f"Parameters: {trainable/1e6:.1f}M trainable / {total/1e6:.1f}M total") # Parameters: 1820.1M trainable / 1822.3M total | |
| optimizer = torch.optim.AdamW( | |
| fsdp_model.parameters(), | |
| lr=training_args.lr, | |
| betas=(training_args.beta1, training_args.beta2), | |
| eps=training_args.eps, | |
| weight_decay=0, | |
| ) | |
| if training_args.lr_scheduler == "cosine": | |
| scheduler = get_cosine_with_min_lr_schedule_with_warmup( | |
| optimizer=optimizer, | |
| num_warmup_steps=training_args.warmup_steps, | |
| num_training_steps=training_args.total_steps, | |
| min_lr=training_args.min_lr, | |
| ) | |
| else: | |
| scheduler = get_constant_schedule_with_warmup( | |
| optimizer=optimizer, num_warmup_steps=training_args.warmup_steps | |
| ) | |
| if resume_model_only: | |
| train_step = 0 | |
| data_status = None | |
| else: | |
| optimizer, scheduler, train_step, data_status = FSDPCheckpoint.try_load_train_state( | |
| resume_from, optimizer, scheduler, fsdp_config | |
| ) | |
| # -------- dataset -------- | |
| with open(data_args.dataset_config_file, "r") as f: | |
| dataset_meta = yaml.safe_load(f) | |
| # Inject the user's max_reference_rounds into every group's kwargs so | |
| # OPSDPairedIterableDataset picks it up. | |
| for grouped_name in list(dataset_meta.keys()): | |
| dataset_meta[grouped_name].setdefault( | |
| "max_reference_rounds", data_args.max_reference_rounds | |
| ) | |
| raw_dataset = build_opsd_raw_dataset( | |
| dataset_meta=dataset_meta, | |
| tokenizer=tokenizer, | |
| local_rank=dist.get_rank(), | |
| world_size=dist.get_world_size(), | |
| num_workers=data_args.num_workers, | |
| data_status=data_status, | |
| ) | |
| raw_dataset.set_epoch(data_args.data_seed) | |
| raw_loader = DataLoader( | |
| raw_dataset, | |
| batch_size=1, | |
| num_workers=data_args.num_workers, | |
| pin_memory=False, # raw envelope has PIL-derived tensors + python lists | |
| collate_fn=_raw_identity_collate, | |
| drop_last=True, | |
| prefetch_factor=data_args.prefetch_factor if data_args.num_workers > 0 else None, | |
| persistent_workers=data_args.num_workers > 0, | |
| ) | |
| # -------- samplers + pack builder -------- | |
| sys_prompt_text = ( | |
| VLM_THINK_SYSTEM_PROMPT if training_args.use_system_prompt else None | |
| ) | |
| sys_prompt_ids = ( | |
| tokenizer.encode(VLM_THINK_SYSTEM_PROMPT, add_special_tokens=False) | |
| if training_args.use_system_prompt | |
| else None | |
| ) | |
| ref_intro_ids = ( | |
| tokenizer.encode(TEACHER_REFERENCE_INTRO, add_special_tokens=False) | |
| if training_args.use_reference_intro | |
| else None | |
| ) | |
| transition_ids = ( | |
| tokenizer.encode(TEACHER_TRANSITION, add_special_tokens=False) | |
| if training_args.use_transition_prompt | |
| else None | |
| ) | |
| instruction_ids = ( | |
| tokenizer.encode(training_args.instruction_suffix, add_special_tokens=False) | |
| if training_args.instruction_suffix | |
| else None | |
| ) | |
| sampler = OnPolicySampler( | |
| tokenizer=tokenizer, | |
| vit_transform=None, | |
| new_token_ids=new_token_ids, | |
| cfg=SamplingConfig( | |
| max_new_tokens=training_args.sample_max_new_tokens, | |
| temperature=training_args.sample_temperature, | |
| do_sample=training_args.sample_do_sample, | |
| system_prompt=sys_prompt_text, | |
| instruction_suffix=( | |
| training_args.instruction_suffix | |
| if training_args.instruction_suffix | |
| else None | |
| ), | |
| max_image_skips=training_args.sample_max_image_skips, | |
| ), | |
| ) | |
| pack_builder = OPSDPackBuilder( | |
| config=PackBuilderConfig( | |
| vit_patch_size=model_args.vit_patch_size, | |
| max_num_patch_per_side=model_args.vit_max_num_patch_per_side, | |
| max_latent_size=model_args.max_latent_size, | |
| interpolate_pos=model_args.interpolate_pos, | |
| use_flex=training_args.use_flex, | |
| ), | |
| special_tokens=new_token_ids, | |
| system_prompt=VLM_THINK_SYSTEM_PROMPT if training_args.use_system_prompt else None, | |
| ) | |
| fsdp_model.train() | |
| if ema_model is not None: | |
| ema_model.eval() | |
| if fixed_teacher is not None: | |
| fixed_teacher.eval() | |
| grad_accum = training_args.gradient_accumulation_steps | |
| logger.info( | |
| f"Training for {training_args.total_steps} steps " | |
| f"(grad_accum={grad_accum}), starting at {train_step}..." | |
| ) | |
| data_iter = iter(raw_loader) | |
| pbar = None | |
| if dist.get_rank() == 0: | |
| pbar = tqdm( | |
| total=training_args.total_steps, | |
| initial=train_step, | |
| desc="Visual-OPSD", | |
| dynamic_ncols=True, | |
| smoothing=0.05, | |
| ) | |
| start_time = time() | |
| curr_step = train_step | |
| for curr_step in range(train_step, training_args.total_steps): | |
| optimizer.zero_grad(set_to_none=True) | |
| # Free AdamW state tensors (m, v) during the sampling + forward | |
| # + backward phase. After step 0 these occupy ~14.5 GiB per | |
| # rank (FP32, 14.5B / 8 GPUs × 2 buffers) and their absence | |
| # during compute is harmless — only optimizer.step() needs them. | |
| _offload_optim_to_cpu(optimizer) | |
| step_ce = 0.0 | |
| step_jsd = 0.0 | |
| step_loss = 0.0 | |
| step_completion_len = 0 | |
| step_data_indexes = [] | |
| for micro in range(grad_accum): | |
| # Fetch-and-sample loop with retry. sampler.generate() calls | |
| # FSDP.summon_full_params() which is a collective allgather: | |
| # every rank must enter it the same number of times. We | |
| # synchronize the break decision via all_reduce so that if | |
| # ANY rank got an empty completion, ALL ranks retry together; | |
| # otherwise the retrying ranks issue an allgather that the | |
| # exited ranks never join, causing a NCCL hang. | |
| max_retries = 3 | |
| completion_ids: list[int] = [] | |
| raw: Dict[str, Any] = {} | |
| for _ in range(max_retries): | |
| try: | |
| raw = next(data_iter) | |
| except StopIteration: | |
| data_iter = iter(raw_loader) | |
| raw = next(data_iter) | |
| raw = _move_image_tensor(raw, device) | |
| if sys_prompt_ids is not None: | |
| raw["_system_ids"] = sys_prompt_ids | |
| if training_args.sample_on_rank0_only: | |
| raw = broadcast_raw(raw, src_rank=0, device=device) | |
| completion_ids = sampler.generate( | |
| fsdp_model=fsdp_model, | |
| raw=raw, | |
| device=device, | |
| rank0_only=training_args.sample_on_rank0_only, | |
| ) | |
| all_ok = torch.tensor( | |
| [1 if completion_ids else 0], | |
| dtype=torch.int64, device=device, | |
| ) | |
| dist.all_reduce(all_ok, op=dist.ReduceOp.MIN) | |
| if all_ok.item() == 1: | |
| break | |
| step_data_indexes.append(raw.get("data_indexes")) | |
| if not completion_ids: | |
| # Hard fallback: use a single <eos>-less sentinel token so we | |
| # still forward/backward and keep FSDP ranks in lockstep. | |
| # The sampled position is essentially garbage but the sync | |
| # proceeds. In practice this path is almost never hit. | |
| completion_ids = [int(new_token_ids["eos_token_id"])] | |
| logger.info( | |
| f"rank{dist.get_rank()}: fallback completion after " | |
| f"{max_retries} empty samples" | |
| ) | |
| if dist.get_rank() == 0 and curr_step % 50 == 0 and micro == 0: | |
| preview = tokenizer.decode(completion_ids) | |
| logger.info(f"[sample@{curr_step}] len={len(completion_ids)} | {preview!r}") | |
| # -------- 2) Build student / teacher packed batches -------- | |
| # Optionally replace privileged VT images with noise (ablation). | |
| if training_args.noise_vt: | |
| vts = raw.get("reference_vt_tensors", []) | |
| raw["reference_vt_tensors"] = [ | |
| torch.randn_like(v) if v is not None else None | |
| for v in vts | |
| ] | |
| # Build the teacher batch first to check sequence length; the | |
| # teacher's packed sequence is always >= the student's because | |
| # it includes the privileged reference context. | |
| teacher_batch = pack_builder.build_teacher_sample( | |
| raw=raw, | |
| completion_ids=completion_ids, | |
| instruction_ids=instruction_ids, | |
| ref_intro_ids=ref_intro_ids, | |
| transition_ids=transition_ids, | |
| ) | |
| # Truncate completion to keep the teacher sequence within the | |
| # forward memory budget. The dense nested-attention mask | |
| # scales as O(L^2); capping L avoids OOM on long completions. | |
| max_fwd = training_args.max_forward_tokens | |
| teacher_seq_len = teacher_batch["sequence_length"] | |
| if teacher_seq_len > max_fwd: | |
| completion_block_len = len(completion_ids) + 2 # BOS + EOS | |
| teacher_overhead = teacher_seq_len - completion_block_len | |
| max_compl = max(max_fwd - teacher_overhead - 2, 1) | |
| if dist.get_rank() == 0: | |
| logger.info( | |
| f"[trunc@{curr_step}] teacher_seq={teacher_seq_len} > " | |
| f"max_fwd={max_fwd}; truncating completion " | |
| f"{len(completion_ids)} → {max_compl}" | |
| ) | |
| completion_ids = completion_ids[:max_compl] | |
| teacher_batch = pack_builder.build_teacher_sample( | |
| raw=raw, | |
| completion_ids=completion_ids, | |
| instruction_ids=instruction_ids, | |
| ref_intro_ids=ref_intro_ids, | |
| transition_ids=transition_ids, | |
| ) | |
| step_completion_len += len(completion_ids) | |
| student_batch = pack_builder.build_student_sample( | |
| raw=raw, | |
| completion_ids=completion_ids, | |
| instruction_ids=instruction_ids, | |
| ) | |
| packed_batch_to_device(student_batch, device) | |
| packed_batch_to_device(teacher_batch, device) | |
| # We don't need per-token ce_loss_weights beyond the flat average. | |
| student_ce_weights = student_batch.pop("ce_loss_weights", None) | |
| _ = teacher_batch.pop("ce_loss_weights", None) | |
| # `completion_ce_count` is a pack-builder metadata key used only by | |
| # analysis / tests; ``Bagel.forward`` does not accept it. Strip it | |
| # on both sides before the forward call. | |
| student_batch.pop("completion_ce_count", None) | |
| teacher_batch.pop("completion_ce_count", None) | |
| # Save metadata needed after the forward before we release the | |
| # batch dicts. | |
| student_ce_count = len(student_batch.get("ce_loss_indexes", [])) | |
| is_last_micro = micro == grad_accum - 1 | |
| sync_ctx = nullcontext() if is_last_micro else fsdp_model.no_sync() | |
| with sync_ctx: | |
| # ---------- Student forward ---------- | |
| with torch.amp.autocast("cuda", enabled=True, dtype=torch.bfloat16): | |
| student_out = fsdp_model(**student_batch, return_logits=True) | |
| # Extract needed values and release the student output dict | |
| # and batch tensors (especially the L*L attention mask) | |
| # *before* the teacher forward so memory can be reused. | |
| ce = student_out.get("ce") | |
| student_logits = student_out.get("logits") | |
| del student_out | |
| # Release the heavy nested_attention_masks tensor. | |
| student_batch.pop("nested_attention_masks", None) | |
| student_batch.clear() | |
| del student_batch | |
| torch.cuda.empty_cache() | |
| # ---------- Teacher forward ---------- | |
| if training_args.teacher_mode == "self": | |
| teacher_module = fsdp_model | |
| elif training_args.teacher_mode == "ema": | |
| assert ema_model is not None, ( | |
| "teacher_mode='ema' requires an EMA model" | |
| ) | |
| teacher_module = ema_model | |
| elif training_args.teacher_mode == "fixed": | |
| assert fixed_teacher is not None | |
| teacher_module = fixed_teacher | |
| else: | |
| raise ValueError( | |
| f"Unknown teacher_mode: {training_args.teacher_mode}" | |
| ) | |
| teacher_was_training = teacher_module.training | |
| teacher_module.train() | |
| try: | |
| with torch.no_grad(), torch.amp.autocast( | |
| "cuda", enabled=True, dtype=torch.bfloat16 | |
| ): | |
| teacher_out = teacher_module( | |
| **teacher_batch, return_logits=True | |
| ) | |
| finally: | |
| if not teacher_was_training: | |
| teacher_module.eval() | |
| # Extract teacher logits and free teacher output + batch. | |
| teacher_logits = teacher_out.get("logits") | |
| del teacher_out | |
| teacher_batch.pop("nested_attention_masks", None) | |
| teacher_batch.clear() | |
| del teacher_batch | |
| torch.cuda.empty_cache() | |
| # ---------- CE on student ---------- | |
| ce_val = 0.0 | |
| if ce is not None: | |
| if student_ce_weights is not None and training_args.ce_loss_reweighting: | |
| ce_scalar = (ce * student_ce_weights).sum() / max( | |
| student_ce_weights.sum().item(), 1.0 | |
| ) | |
| else: | |
| ce_scalar = ce.sum() / max(student_ce_count, 1) | |
| ce_val = ce_scalar.item() | |
| else: | |
| ce_scalar = torch.zeros((), device=device) | |
| # ---------- JSD on full completion ---------- | |
| # Pass logits in the native dtype (bf16); generalized_jsd_loss | |
| # applies top_k *before* the float32 upcast so only the | |
| # top-k slice is ever materialised in FP32. | |
| jsd_val = 0.0 | |
| jsd_scalar = torch.zeros((), device=device) | |
| if ( | |
| student_logits is not None | |
| and teacher_logits is not None | |
| and student_logits.shape[0] > 0 | |
| and teacher_logits.shape[0] > 0 | |
| ): | |
| n = min(student_logits.shape[0], teacher_logits.shape[0]) | |
| jsd_scalar = compute_student_kd_loss( | |
| student_logits[:n], | |
| teacher_logits[:n], | |
| sampled_token_ids=None, | |
| labels=None, | |
| loss_kind=training_args.jsd_loss_kind, | |
| beta=training_args.jsd_beta, | |
| temperature=training_args.jsd_temperature, | |
| top_k=( | |
| training_args.jsd_top_k | |
| if training_args.jsd_top_k > 0 | |
| else None | |
| ), | |
| token_clip=( | |
| training_args.jsd_token_clip | |
| if training_args.jsd_token_clip > 0 | |
| else None | |
| ), | |
| ) | |
| jsd_val = jsd_scalar.item() | |
| del teacher_logits, student_logits | |
| micro_loss = ( | |
| training_args.ce_weight * ce_scalar | |
| + training_args.jsd_weight * jsd_scalar | |
| ) | |
| (micro_loss / grad_accum).backward() | |
| step_ce += ce_val | |
| step_jsd += jsd_val | |
| step_loss += micro_loss.item() | |
| total_norm = fsdp_model.clip_grad_norm_(training_args.max_grad_norm) | |
| _restore_optim_to_gpu(optimizer, device) | |
| optimizer.step() | |
| scheduler.step() | |
| if ema_model is not None: | |
| fsdp_ema_update( | |
| ema_model, | |
| fsdp_model, | |
| decay=( | |
| training_args.ema_decay | |
| if training_args.teacher_mode == "ema" | |
| else training_args.ema | |
| ), | |
| ) | |
| avg_ce = step_ce / max(grad_accum, 1) | |
| avg_jsd = step_jsd / max(grad_accum, 1) | |
| avg_loss = step_loss / max(grad_accum, 1) | |
| avg_completion_len = step_completion_len / max(grad_accum, 1) | |
| if pbar is not None: | |
| pbar.set_postfix( | |
| ce=f"{avg_ce:.4f}", | |
| jsd=f"{avg_jsd:.4f}", | |
| loss=f"{avg_loss:.4f}", | |
| comp_len=f"{avg_completion_len:.0f}", | |
| lr=f"{optimizer.param_groups[0]['lr']:.1e}", | |
| ) | |
| pbar.update(1) | |
| if curr_step % training_args.log_every == 0 and curr_step > train_step: | |
| torch.cuda.synchronize() | |
| elapsed = time() - start_time | |
| steps_done = training_args.log_every | |
| sps = steps_done / elapsed if elapsed > 0 else 0.0 | |
| eta_h = ( | |
| (training_args.total_steps - curr_step) / sps / 3600 | |
| if sps > 0 | |
| else float("inf") | |
| ) | |
| logger.info( | |
| f"(step={curr_step:07d}) CE: {avg_ce:.4f}, " | |
| f"JSD: {avg_jsd:.4f}, Loss: {avg_loss:.4f}, " | |
| f"comp_len: {avg_completion_len:.0f}, " | |
| f"Steps/Sec: {sps:.2f}, ETA: {eta_h:.1f}h" | |
| ) | |
| if dist.get_rank() == 0: | |
| wandb.log( | |
| { | |
| "ce": avg_ce, | |
| "jsd": avg_jsd, | |
| "total_loss": avg_loss, | |
| "completion_len": avg_completion_len, | |
| "lr": optimizer.param_groups[0]["lr"], | |
| "total_norm": total_norm.item(), | |
| "steps_per_sec": sps, | |
| }, | |
| step=curr_step, | |
| ) | |
| start_time = time() | |
| if data_status is None: | |
| data_status = {} | |
| for item in step_data_indexes: | |
| if item is None: | |
| continue | |
| dn = item.get("dataset_name") | |
| if dn is None: | |
| continue | |
| if dn not in data_status: | |
| data_status[dn] = {} | |
| data_status[dn][item["worker_id"]] = item["data_indexes"] | |
| if curr_step > 0 and curr_step % training_args.save_every == 0: | |
| gather_list = [None] * dist.get_world_size() | |
| dist.all_gather_object(gather_list, data_status) | |
| FSDPCheckpoint.fsdp_save_ckpt( | |
| ckpt_dir=training_args.checkpoint_dir, | |
| train_steps=curr_step, | |
| model=fsdp_model, | |
| ema_model=ema_model, | |
| optimizer=optimizer, | |
| scheduler=scheduler, | |
| logger=logger, | |
| fsdp_config=fsdp_config, | |
| data_status=gather_list, | |
| ) | |
| if pbar is not None: | |
| pbar.close() | |
| gather_list = [None] * dist.get_world_size() | |
| dist.all_gather_object(gather_list, data_status) | |
| FSDPCheckpoint.fsdp_save_ckpt( | |
| ckpt_dir=training_args.checkpoint_dir, | |
| train_steps=curr_step, | |
| model=fsdp_model, | |
| ema_model=ema_model, | |
| optimizer=optimizer, | |
| scheduler=scheduler, | |
| logger=logger, | |
| fsdp_config=fsdp_config, | |
| data_status=gather_list, | |
| ) | |
| logger.info("Visual-OPSD training finished.") | |
| if dist.get_rank() == 0: | |
| wandb.finish() | |
| dist.destroy_process_group() | |
| if __name__ == "__main__": | |
| main() | |
Xet Storage Details
- Size:
- 45.2 kB
- Xet hash:
- 67901e50667cc739e7e97b88de439f017b03e6e66b34a98b4aaab00a12df9d7d
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.