Buckets:
| """On-policy completion sampling for Visual-OPSD training. | |
| During training, the student must generate its own completion (reasoning + | |
| answer) from the current policy, and the teacher then *scores* that very | |
| completion under a privileged prompt. This module implements the first | |
| half of that loop: given the raw student inputs (problem image + question), | |
| produce a list of sampled token ids. | |
| Key design points | |
| ----------------- | |
| 1. The underlying model is an FSDP-wrapped ``Bagel``. To run the BAGEL | |
| inference primitives (``prepare_prompts``, ``forward_cache_update_text``, | |
| ``prepare_vit_images``, ``forward_cache_update_vit``, ``generate_text``) | |
| we need a module whose parameters are materialised on every rank. We | |
| use ``FSDP.summon_full_params(..., recurse=True, writeback=False)`` | |
| which gathers every nested FSDP unit for the duration of the context. | |
| 2. BAGEL's ``generate_text`` supports batch size one only. The training | |
| loop is expected to call ``generate`` once per raw sample. Gradient | |
| accumulation and data parallelism happen at the outer level. | |
| 3. The returned token ids strip BOTH the leading BOS (inserted by | |
| ``prepare_start_tokens``) AND the trailing EOS (the break condition | |
| inside ``generate_text`` does not append eos). The training pack | |
| builder wraps the completion with its own ``<|im_start|>``/``<|im_end|>`` | |
| pair via ``pack_sequence``, so we do not add them here. | |
| 4. BAGEL's inference primitives internally invoke FSDP-wrapped submodules | |
| (ViT transformer, Qwen2 decoder layers). Each such call goes through | |
| ``FullyShardedDataParallel.forward``, whose post-forward logic | |
| **reshards** the flat parameter — even when we are inside | |
| ``summon_full_params(recurse=True)``. That re-shard violates the | |
| exit invariant of ``summon_full_params`` ("expects tensor to be | |
| unsharded" assertion). We therefore bypass ``FSDP.forward`` on every | |
| FSDP instance for the duration of sampling (see | |
| ``_bypass_fsdp_forward``). Parameters are guaranteed unsharded by | |
| the enclosing summon context, so the pass-through to | |
| ``_fsdp_wrapped_module`` is safe. | |
| """ | |
| from __future__ import annotations | |
| import contextlib | |
| from dataclasses import dataclass | |
| from types import MethodType | |
| from typing import Any, Dict, Iterator, List, Optional | |
| import torch | |
| import torch.distributed as dist | |
| import torch.nn as nn | |
| from torch.distributed.fsdp import FullyShardedDataParallel as FSDP | |
| from torch.distributed.fsdp._flat_param import FlatParamHandle | |
| from modeling.bagel.qwen2_navit import NaiveCache | |
| class SamplingConfig: | |
| max_new_tokens: int = 512 | |
| temperature: float = 1.0 | |
| do_sample: bool = True | |
| system_prompt: Optional[str] = None | |
| instruction_suffix: Optional[str] = None # appended to question text | |
| # Because the UMM student is heavily fine-tuned on interleaved CoT data, | |
| # an overfit student often emits ``<image_start>`` then EOS in | |
| # anticipation of image generation; a single text-only ``generate_text`` | |
| # round therefore stops with only ``<think>...</think><image_start>`` as | |
| # the completion. To keep the on-policy completion well-formed text we | |
| # inject a ``<|vision_end|>`` sentinel after each premature | |
| # ``<image_start>`` and keep sampling. This is the maximum number of | |
| # such image-skip rounds to attempt (0 disables the fix and produces | |
| # legacy one-shot behaviour). | |
| max_image_skips: int = 2 | |
| # ------------------------------------------------------------------ # | |
| # FSDP precision patch | |
| # ------------------------------------------------------------------ # | |
| def _patch_fsdp_force_full_precision_off() -> Iterator[None]: | |
| """Temporarily make ``FlatParamHandle._force_full_precision`` always False. | |
| Why | |
| --- | |
| Inside ``FSDP.summon_full_params`` the handle sets its training state to | |
| ``SUMMON_FULL_PARAMS``. That in turn makes ``_force_full_precision`` | |
| evaluate to ``True`` whenever mixed-precision training is enabled, so | |
| FSDP allocates the unsharded ``_full_prec_full_param_padded`` tensor | |
| in FP32 rather than reusing the BF16 ``_full_param_padded``. For a | |
| ~14.5 B-parameter model that is the difference between a 29 GB and a | |
| 58 GB per-rank allocation, which easily OOMs an 80 GB GPU once you | |
| account for: | |
| * the sharded FP32 master weights of the student (~7 GB), | |
| * the sharded FP32 master weights of the EMA teacher (~7 GB), | |
| * the AdamW state (~14 GB), and | |
| * the CUDA context + activations. | |
| What | |
| ---- | |
| We override the ``_force_full_precision`` property at the class level | |
| to return ``False``. This diverts FSDP onto the normal mixed-precision | |
| path: | |
| * ``pre_unshard`` -> ``_use_low_precision_shard`` (allocate | |
| ``_mp_shard`` in BF16, downcast from the FP32 master), | |
| * ``_get_padded_unsharded_flat_param`` returns the BF16 | |
| ``_full_param_padded``, | |
| * ``_all_gather_flat_param`` targets the BF16 tensor, | |
| * ``post_unshard`` frees ``_mp_shard``. | |
| The FP32 master shards (``_local_shard``) are never touched, so on | |
| context exit all optimizer state remains intact. | |
| Safety | |
| ------ | |
| * We restore the original property on exit (success or exception). | |
| * The override affects every ``FlatParamHandle`` instance globally for | |
| the duration of the context; this is acceptable because sampling is | |
| synchronous and there is only one FSDP tree in the training process. | |
| * Only valid under ``torch.no_grad`` (we never flow gradients through | |
| the BF16 views). | |
| """ | |
| cls = FlatParamHandle | |
| sentinel = object() | |
| original = cls.__dict__.get("_force_full_precision", sentinel) | |
| cls._force_full_precision = property(lambda self: False) | |
| try: | |
| yield | |
| finally: | |
| if original is sentinel: | |
| try: | |
| delattr(cls, "_force_full_precision") | |
| except AttributeError: | |
| pass | |
| else: | |
| setattr(cls, "_force_full_precision", original) | |
| # ------------------------------------------------------------------ # | |
| # FSDP bypass context | |
| # ------------------------------------------------------------------ # | |
| def _bypass_fsdp_forward(root_module: nn.Module) -> Iterator[None]: | |
| """Temporarily override ``forward`` on every ``FullyShardedDataParallel`` | |
| instance in ``root_module`` so it is a direct pass-through to the | |
| wrapped module. | |
| Rationale | |
| --------- | |
| In wrapper-mode FSDP (what we use), the pre/post-forward unshard and | |
| reshard logic is invoked *inline* inside | |
| ``FullyShardedDataParallel.forward`` — not via ``register_forward_hook``. | |
| That means the only way to prevent an inner FSDP module from | |
| resharding its flat parameter when it is called is to avoid running | |
| ``FSDP.forward`` at all. While we are inside | |
| ``summon_full_params(recurse=True)`` all flat parameters are already | |
| unsharded, so a direct call to the wrapped module will read the | |
| correct full-precision tensors and produce the right output. | |
| Safety | |
| ------ | |
| * Only valid inside ``summon_full_params(recurse=True, writeback=False)``. | |
| * We operate under ``torch.no_grad`` in the caller, so the | |
| post-forward backward-hook registration we skip is unnecessary. | |
| * ``_root_pre_forward`` one-time setup has already been performed | |
| during normal training forwards, and no new allocation is needed | |
| here. | |
| """ | |
| def _direct_forward(self: FSDP, *args: Any, **kwargs: Any) -> Any: | |
| return self._fsdp_wrapped_module(*args, **kwargs) | |
| patched: List[FSDP] = [] | |
| for mod in root_module.modules(): | |
| if isinstance(mod, FSDP): | |
| mod.__dict__["forward"] = MethodType(_direct_forward, mod) | |
| patched.append(mod) | |
| try: | |
| yield | |
| finally: | |
| for mod in patched: | |
| mod.__dict__.pop("forward", None) | |
| class OnPolicySampler: | |
| """Generate text completions from the *current* FSDP student model.""" | |
| def __init__( | |
| self, | |
| tokenizer, | |
| vit_transform, | |
| new_token_ids: Dict[str, int], | |
| cfg: SamplingConfig, | |
| ) -> None: | |
| self.tokenizer = tokenizer | |
| self.vit_transform = vit_transform | |
| self.new_token_ids = new_token_ids | |
| self.cfg = cfg | |
| # ------------------------------------------------------------------ # | |
| # utilities | |
| # ------------------------------------------------------------------ # | |
| def _to_device(generation_input: Dict[str, Any], device) -> Dict[str, Any]: | |
| out = {} | |
| for k, v in generation_input.items(): | |
| if torch.is_tensor(v): | |
| out[k] = v.to(device, non_blocking=True) | |
| else: | |
| out[k] = v | |
| return out | |
| def _build_student_prompt_text(self, raw: Dict[str, Any]) -> str: | |
| """Compose the question-only text that precedes the completion. | |
| We intentionally avoid including the reference reasoning here — | |
| only the student gets this prompt. Matches ``build_student_sample`` | |
| in ``data/opsd_pack_builder.py``. | |
| """ | |
| text = raw["question_text"] | |
| if self.cfg.instruction_suffix: | |
| text = f"{text}{self.cfg.instruction_suffix}" | |
| return text | |
| # ------------------------------------------------------------------ # | |
| # public API | |
| # ------------------------------------------------------------------ # | |
| def generate( | |
| self, | |
| fsdp_model, | |
| raw: Dict[str, Any], | |
| device, | |
| dtype: torch.dtype = torch.bfloat16, | |
| rank0_only: bool = False, | |
| ) -> List[int]: | |
| """Sample one completion under the student prompt. | |
| Returns the list of token ids (excluding the leading BOS and any | |
| trailing EOS — ready to be wrapped by the pack builder). | |
| Parameters | |
| ---------- | |
| rank0_only: | |
| If True, materialize the full unsharded student on global rank 0 | |
| only and broadcast the sampled completion ids to every other | |
| rank. All ranks still enter the summon context (collective | |
| all-gather), but ``_full_param_padded`` is allocated on rank 0 | |
| only, saving ~one full-model BF16 copy of GPU memory | |
| (~29 GB for a 14.5 B MoT backbone) on every non-rank-0 rank — | |
| which is the dominant transient allocation that puts | |
| ``summon_full_params`` over the 80 GB/GPU budget once FP32 | |
| AdamW state and a forward-only fixed_teacher are resident. | |
| Because the student is identical across ranks and RNG is | |
| identical within a data-parallel group, there is no loss of | |
| sampling diversity relative to sampling-on-every-rank. | |
| """ | |
| eos_id = int(self.new_token_ids["eos_token_id"]) | |
| bos_id = int(self.new_token_ids["bos_token_id"]) | |
| dist_ready = dist.is_available() and dist.is_initialized() | |
| world_size = dist.get_world_size() if dist_ready else 1 | |
| my_rank = dist.get_rank() if dist_ready else 0 | |
| # Only flip the rank0-only path on when we actually have more than | |
| # one rank — otherwise there is nothing to save and nothing to | |
| # broadcast. | |
| use_rank0_only = bool(rank0_only) and world_size > 1 | |
| is_sampler_rank = (not use_rank0_only) or my_rank == 0 | |
| # `summon_full_params` gathers *all* FSDP-owned parameters on every | |
| # rank. ``writeback=False`` asks FSDP not to copy-back any | |
| # modifications — we're read-only here. ``recurse=True`` is the | |
| # default and what we need given BAGEL's nested FSDP layout. | |
| # | |
| # ``_patch_fsdp_force_full_precision_off`` forces the unsharded | |
| # tensors to stay in BF16 instead of being upcast to FP32 (which | |
| # would double the per-rank memory footprint and OOM on 80 GB GPUs | |
| # for the ~14.5 B parameter MoT backbone). | |
| # | |
| # ``_bypass_fsdp_forward`` replaces every FSDP instance's | |
| # ``forward`` with a direct pass-through so that BAGEL's inference | |
| # primitives, which invoke nested FSDP submodules via their | |
| # ``__call__``, do NOT accidentally reshard params mid-generation | |
| # (which would break the summon_full_params exit invariant). | |
| # | |
| # Release any cached but unused allocator blocks before we ask | |
| # FSDP to allocate the full unsharded BF16 parameter tensor — on | |
| # 80 GB H800s with three FSDP models and FP32 AdamW state the | |
| # allocation is within a few hundred MiB of the budget, and a | |
| # single fragmented segment is enough to tip it into OOM. | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| with _patch_fsdp_force_full_precision_off(), FSDP.summon_full_params( | |
| fsdp_model, | |
| recurse=True, | |
| writeback=False, | |
| offload_to_cpu=False, | |
| rank0_only=use_rank0_only, | |
| ), _bypass_fsdp_forward(fsdp_model): | |
| if is_sampler_rank: | |
| # FSDP module.__getattr__ forwards to the inner Bagel for | |
| # any missing attribute. All the methods we call below | |
| # are @torch.no_grad inside BAGEL, so gradient state is | |
| # neutral. | |
| model = fsdp_model | |
| was_training = model.training | |
| model.eval() | |
| try: | |
| with torch.autocast( | |
| device_type="cuda", | |
| enabled=torch.cuda.is_available(), | |
| dtype=dtype, | |
| ): | |
| generated_ids = self._generate_single( | |
| model=model, | |
| raw=raw, | |
| device=device, | |
| eos_id=eos_id, | |
| ) | |
| finally: | |
| if was_training: | |
| model.train() | |
| else: | |
| generated_ids = None | |
| # generated_ids is shape [T, 1] (BAGEL's generate_text). Convert | |
| # and strip the leading BOS that prepare_start_tokens inserts. | |
| if is_sampler_rank: | |
| if isinstance(generated_ids, torch.Tensor): | |
| token_list = generated_ids.view(-1).tolist() | |
| else: | |
| token_list = [int(t) for t in generated_ids] if generated_ids is not None else [] | |
| if token_list and int(token_list[0]) == bos_id: | |
| token_list = token_list[1:] | |
| while token_list and int(token_list[-1]) == eos_id: | |
| token_list = token_list[:-1] | |
| token_list = [int(t) for t in token_list] | |
| else: | |
| token_list = None | |
| # Broadcast completion ids from rank 0 to all other ranks in the | |
| # rank0_only path. We broadcast *after* exiting summon so that | |
| # the extra buffer is reclaimed on rank 0 before the collective | |
| # runs (minor, but avoids holding the ~29 GB unshard buffer | |
| # across the broadcast). | |
| if use_rank0_only: | |
| token_list = broadcast_completion(token_list, src_rank=0) | |
| return token_list if token_list is not None else [] | |
| # ------------------------------------------------------------------ # | |
| # internal: build prompt KV cache and sample | |
| # ------------------------------------------------------------------ # | |
| def _generate_single( | |
| self, | |
| model, | |
| raw: Dict[str, Any], | |
| device, | |
| eos_id: int, | |
| ) -> torch.Tensor: | |
| """Run BAGEL inference primitives; return [T, 1] token tensor. | |
| When the policy is overfit on interleaved CoT, a single | |
| ``generate_text`` round typically stops right after the model | |
| emits ``<image_start>`` (followed by ``<|im_end|>``), giving a | |
| degenerate completion such as ``<think>..</think><image_start>`` | |
| with no reasoning tail and no answer. Training on that induces | |
| a "length-collapse" failure mode. | |
| To avoid this, after detecting ``<image_start>`` in the round's | |
| decoded text we close the current chat turn by appending a lone | |
| ``<|im_end|>`` to the cache (``generate_text`` broke before | |
| forwarding the sampled EOS, so the cache is missing it), and | |
| let the student keep sampling. The second round then naturally | |
| starts with ``<image_end>`` — which is how the training data's | |
| second block begins — followed by the post-image reasoning and | |
| ``<answer>..</answer>``. We concatenate the rounds directly | |
| (dropping each round's leading BOS) to obtain the full | |
| interleaved chain. Up to ``max_image_skips`` such skips are | |
| performed; if the last round still ends on ``<image_start>`` we | |
| stop and return what we have. | |
| Note that ``<image_start>`` and ``<image_end>`` are *literal* | |
| multi-token strings ([27, 1805, 4906, 29] / [27, 1805, 6213, 29]) | |
| in the Qwen tokenizer — NOT the ``<|vision_start|>/<|vision_end|>`` | |
| special ids. So we detect via ``tokenizer.decode`` + substring | |
| instead of comparing token ids. | |
| """ | |
| cache = NaiveCache(model.config.llm_config.num_hidden_layers) | |
| kv_lens = [0] | |
| ropes = [0] | |
| bos_id = int(self.new_token_ids["bos_token_id"]) | |
| max_skips = max(0, int(getattr(self.cfg, "max_image_skips", 0))) | |
| # 1) Optional system prompt. | |
| if self.cfg.system_prompt: | |
| generation_input, kv_lens, ropes = model.prepare_prompts( | |
| curr_kvlens=kv_lens, | |
| curr_rope=ropes, | |
| prompts=[self.cfg.system_prompt], | |
| tokenizer=self.tokenizer, | |
| new_token_ids=self.new_token_ids, | |
| ) | |
| generation_input = self._to_device(generation_input, device) | |
| cache = model.forward_cache_update_text(cache, **generation_input) | |
| # 2) Problem image (ViT). | |
| # ``prepare_vit_images`` expects a PIL image which it transforms via | |
| # the passed ``transforms``. Our raw envelope already has a | |
| # transformed tensor, but passing it back out as an image would be | |
| # wasteful — so we forward the tensor directly by subclassing the | |
| # call below and rebuilding the primitive inline. | |
| image_tensor = raw["problem_image_tensor"].to(device) | |
| cache, kv_lens, ropes = _forward_cache_update_vit_from_tensor( | |
| model=model, | |
| past_key_values=cache, | |
| curr_kvlens=kv_lens, | |
| curr_rope=ropes, | |
| image_tensor=image_tensor, | |
| new_token_ids=self.new_token_ids, | |
| device=device, | |
| ) | |
| # 3) Question (+ optional instruction suffix). | |
| prompt_text = self._build_student_prompt_text(raw) | |
| generation_input, kv_lens, ropes = model.prepare_prompts( | |
| curr_kvlens=kv_lens, | |
| curr_rope=ropes, | |
| prompts=[prompt_text], | |
| tokenizer=self.tokenizer, | |
| new_token_ids=self.new_token_ids, | |
| ) | |
| generation_input = self._to_device(generation_input, device) | |
| cache = model.forward_cache_update_text(cache, **generation_input) | |
| # 4) Iterative sampling with optional image-skip continuation. | |
| # | |
| # ``self.cfg.max_new_tokens`` is interpreted as the TOTAL budget | |
| # across all skip-image rounds (plus the one-token ``<|im_end|>`` | |
| # sentinel we inject between rounds). Historically this was a | |
| # per-round cap, which meant ``max_new_tokens=512`` with | |
| # ``max_image_skips=1`` could produce completions of up to | |
| # ~1024 tokens — surprising for callers that set the cap for | |
| # memory budgeting. The tracked accounting includes the leading | |
| # BOS of each round, so with two rounds the maximum user-visible | |
| # completion length is ``max_new_tokens - 1 - num_rounds`` in the | |
| # worst case, which is close enough to the requested cap. | |
| total_budget = max(1, int(self.cfg.max_new_tokens)) | |
| consumed = 0 | |
| round_tensors: List[torch.Tensor] = [] | |
| skips_used = 0 | |
| while True: | |
| remaining = total_budget - consumed | |
| if remaining <= 0: | |
| break | |
| gen_input = model.prepare_start_tokens( | |
| kv_lens, ropes, self.new_token_ids | |
| ) | |
| gen_input = self._to_device(gen_input, device) | |
| round_tokens = model.generate_text( | |
| past_key_values=cache, | |
| max_length=remaining, | |
| do_sample=self.cfg.do_sample, | |
| temperature=self.cfg.temperature, | |
| end_token_id=eos_id, | |
| **gen_input, | |
| ) | |
| round_tensors.append(round_tokens) | |
| # ``generate_text`` forwarded every token in ``round_tokens`` | |
| # through the cache (including the leading BOS), so both the | |
| # cache length and the rope offset advance by that many slots. | |
| round_len = int(round_tokens.shape[0]) | |
| consumed += round_len | |
| kv_lens = [kv_lens[0] + round_len] | |
| ropes = [ropes[0] + round_len] | |
| if skips_used >= max_skips: | |
| break | |
| # Only retry when (a) the round actually stopped on EOS (not | |
| # on the remaining budget — those are already "long enough") | |
| # and (b) the decoded round contains ``<image_start>`` (the | |
| # literal ThinkMorph text marker, tokenised as multiple | |
| # regular tokens; see the docstring above). | |
| stopped_on_eos = round_len < remaining | |
| if not stopped_on_eos: | |
| break | |
| # No budget left for the turn-sentinel + at least one new | |
| # content token in the next round — return what we have. | |
| if total_budget - consumed <= 1: | |
| break | |
| round_list = round_tokens.view(-1).tolist() | |
| content_ids = ( | |
| round_list[1:] | |
| if round_list and int(round_list[0]) == bos_id | |
| else round_list | |
| ) | |
| decoded = ( | |
| self.tokenizer.decode(content_ids) if content_ids else "" | |
| ) | |
| if "<image_start>" not in decoded: | |
| break | |
| # Close the current chat turn in the cache. ``generate_text`` | |
| # broke right after sampling ``<|im_end|>`` without ever | |
| # forwarding it, so the cache still sits mid-turn. Appending | |
| # the EOS lets the next ``prepare_start_tokens``-emitted | |
| # ``<|im_start|>`` open a fresh, properly-delimited turn. | |
| # We do NOT inject any ``<image_end>`` bytes here: in the | |
| # training data the post-image block already *starts* with | |
| # ``<image_end>``, so we let the policy re-emit it naturally. | |
| cache = _forward_cache_append_text_tokens( | |
| model=model, | |
| past_key_values=cache, | |
| curr_kvlen=kv_lens[0], | |
| curr_rope=ropes[0], | |
| token_ids=[eos_id], | |
| device=device, | |
| ) | |
| kv_lens = [kv_lens[0] + 1] | |
| ropes = [ropes[0] + 1] | |
| consumed += 1 # the injected ``<|im_end|>`` counts too. | |
| skips_used += 1 | |
| # 5) Flatten rounds into one ``[T, 1]`` tensor expected by the | |
| # caller: keep the first round's leading BOS (the outer | |
| # ``generate()`` strips one BOS) and drop the leading BOS of | |
| # every subsequent round. Each round's content is concatenated | |
| # directly — the next round will already start with | |
| # ``<image_end>`` (training's block-2 prefix) so no extra | |
| # separator is needed. The resulting completion ids look like: | |
| # | |
| # <think>..</think><image_start><image_end><think>.. | |
| # </think><answer>..</answer> | |
| # | |
| # which is the pure-text view of the original interleaved data | |
| # once the VT image block is removed — exactly what Visual-OPSD with | |
| # ``--visual_gen False`` expects to score. | |
| if len(round_tensors) == 1: | |
| return round_tensors[0] | |
| first = round_tensors[0] | |
| pieces: List[torch.Tensor] = [first] | |
| for nxt in round_tensors[1:]: | |
| if nxt.numel() > 0 and int(nxt[0, 0].item()) == bos_id: | |
| nxt = nxt[1:] | |
| if nxt.numel() > 0: | |
| pieces.append(nxt) | |
| return torch.cat(pieces, dim=0) | |
| # --------------------------------------------------------------------- # | |
| # Helper: forward an already-transformed ViT tensor through the cache | |
| # --------------------------------------------------------------------- # | |
| def _forward_cache_update_vit_from_tensor( | |
| model, | |
| past_key_values: NaiveCache, | |
| curr_kvlens: List[int], | |
| curr_rope: List[int], | |
| image_tensor: torch.Tensor, | |
| new_token_ids: Dict[str, int], | |
| device, | |
| ): | |
| """Mirror of ``Bagel.prepare_vit_images`` + ``forward_cache_update_vit``, | |
| but takes a pre-transformed tensor instead of a PIL image. | |
| Exists to avoid re-transforming the problem image that the dataset | |
| already normalised / resized for ViT. | |
| """ | |
| from data.data_utils import patchify | |
| curr_kvlen = curr_kvlens[0] | |
| curr_position_id = curr_rope[0] | |
| packed_key_value_indexes = list(range(0, curr_kvlen)) | |
| packed_text_ids: List[int] = [int(new_token_ids["start_of_image"])] | |
| packed_text_indexes: List[int] = [0] | |
| packed_indexes: List[int] = [curr_kvlen] | |
| vit_position_ids = model.get_flattened_position_ids( | |
| image_tensor.size(1), | |
| image_tensor.size(2), | |
| model.vit_patch_size, | |
| max_num_patches_per_side=model.vit_max_num_patch_per_side, | |
| ) | |
| vit_tokens = patchify(image_tensor, model.vit_patch_size) | |
| num_img_tokens = vit_tokens.shape[0] | |
| # indexes within the local packed block (including start/end image tokens) | |
| packed_vit_token_indexes = list(range(1, 1 + num_img_tokens)) | |
| packed_indexes.extend(range(curr_kvlen + 1, curr_kvlen + 1 + num_img_tokens)) | |
| packed_text_ids.append(int(new_token_ids["end_of_image"])) | |
| packed_text_indexes.append(1 + num_img_tokens) | |
| packed_indexes.append(curr_kvlen + 1 + num_img_tokens) | |
| packed_seqlens = [num_img_tokens + 2] | |
| packed_position_ids = [curr_position_id] * (num_img_tokens + 2) | |
| new_kvlens = [curr_kvlen + num_img_tokens + 2] | |
| new_ropes = [curr_position_id + 1] | |
| generation_input = { | |
| "packed_text_ids": torch.tensor(packed_text_ids, dtype=torch.long, device=device), | |
| "packed_text_indexes": torch.tensor( | |
| packed_text_indexes, dtype=torch.long, device=device | |
| ), | |
| "vit_token_seqlens": torch.tensor( | |
| [num_img_tokens], dtype=torch.int, device=device | |
| ), | |
| "packed_vit_tokens": vit_tokens.to(device), | |
| "packed_vit_position_ids": vit_position_ids.to(device), | |
| "packed_vit_token_indexes": torch.tensor( | |
| packed_vit_token_indexes, dtype=torch.long, device=device | |
| ), | |
| "packed_position_ids": torch.tensor( | |
| packed_position_ids, dtype=torch.long, device=device | |
| ), | |
| "packed_seqlens": torch.tensor(packed_seqlens, dtype=torch.int, device=device), | |
| "packed_indexes": torch.tensor(packed_indexes, dtype=torch.long, device=device), | |
| "packed_key_value_indexes": torch.tensor( | |
| packed_key_value_indexes, dtype=torch.long, device=device | |
| ), | |
| "key_values_lens": torch.tensor(curr_kvlens, dtype=torch.int, device=device), | |
| } | |
| past_key_values = model.forward_cache_update_vit(past_key_values, **generation_input) | |
| return past_key_values, new_kvlens, new_ropes | |
| # --------------------------------------------------------------------- # | |
| # Helper: append raw tokens to the text KV cache | |
| # --------------------------------------------------------------------- # | |
| def _forward_cache_append_text_tokens( | |
| model, | |
| past_key_values, | |
| curr_kvlen: int, | |
| curr_rope: int, | |
| token_ids: List[int], | |
| device, | |
| ): | |
| """Append ``token_ids`` (as-is, no auto BOS/EOS wrapping) to the text | |
| KV cache. | |
| Equivalent to ``Bagel.prepare_prompts`` + ``Bagel.forward_cache_update_text`` | |
| minus the automatic ``[bos, ..., eos]`` wrap, so the caller has full | |
| control over which structural delimiters end up in the cache. Useful | |
| when we need to close a previous chat turn (by appending a single | |
| ``<|im_end|>``) without opening and closing a whole new turn. | |
| """ | |
| n = len(token_ids) | |
| if n == 0: | |
| return past_key_values | |
| packed_text_ids = torch.tensor( | |
| [int(t) for t in token_ids], dtype=torch.long, device=device | |
| ) | |
| packed_text_position_ids = torch.tensor( | |
| list(range(curr_rope, curr_rope + n)), dtype=torch.long, device=device | |
| ) | |
| text_token_lens = torch.tensor([n], dtype=torch.int, device=device) | |
| packed_text_indexes = torch.tensor( | |
| list(range(curr_kvlen, curr_kvlen + n)), dtype=torch.long, device=device | |
| ) | |
| packed_key_value_indexes = torch.tensor( | |
| list(range(curr_kvlen)), dtype=torch.long, device=device | |
| ) | |
| key_values_lens = torch.tensor([curr_kvlen], dtype=torch.int, device=device) | |
| generation_input = { | |
| "text_token_lens": text_token_lens, | |
| "packed_text_ids": packed_text_ids, | |
| "packed_text_position_ids": packed_text_position_ids, | |
| "packed_text_indexes": packed_text_indexes, | |
| "packed_key_value_indexes": packed_key_value_indexes, | |
| "key_values_lens": key_values_lens, | |
| } | |
| return model.forward_cache_update_text(past_key_values, **generation_input) | |
| # --------------------------------------------------------------------- # | |
| # Distributed broadcast helpers | |
| # --------------------------------------------------------------------- # | |
| def broadcast_completion( | |
| completion_ids: Optional[List[int]], src_rank: int = 0 | |
| ) -> List[int]: | |
| """Broadcast a sampled completion (python list of ints) from ``src_rank`` | |
| to all ranks. Used in conjunction with ``rank0_only=True`` sampling | |
| to cut GPU memory during FSDP.summon_full_params. | |
| """ | |
| if not dist.is_available() or not dist.is_initialized(): | |
| return completion_ids or [] | |
| obj_list = [completion_ids if dist.get_rank() == src_rank else None] | |
| dist.broadcast_object_list(obj_list, src=src_rank) | |
| return obj_list[0] or [] | |
| def _to_cpu_picklable(obj: Any) -> Any: | |
| """Recursively convert CUDA tensors to CPU tensors so that ``obj`` | |
| can be pickled and broadcast via ``dist.broadcast_object_list``. | |
| ``dist.broadcast_object_list`` would do a CPU roundtrip anyway, so | |
| eagerly releasing the source-rank's CUDA copies is harmless. | |
| """ | |
| if isinstance(obj, torch.Tensor): | |
| return obj.detach().to("cpu", copy=False) | |
| if isinstance(obj, dict): | |
| return {k: _to_cpu_picklable(v) for k, v in obj.items()} | |
| if isinstance(obj, (list, tuple)): | |
| conv = [_to_cpu_picklable(v) for v in obj] | |
| return type(obj)(conv) if isinstance(obj, tuple) else conv | |
| return obj | |
| def _to_device(obj: Any, device) -> Any: | |
| """Inverse of ``_to_cpu_picklable``: move leaf tensors onto ``device``.""" | |
| if isinstance(obj, torch.Tensor): | |
| return obj.to(device, non_blocking=True) | |
| if isinstance(obj, dict): | |
| return {k: _to_device(v, device) for k, v in obj.items()} | |
| if isinstance(obj, (list, tuple)): | |
| conv = [_to_device(v, device) for v in obj] | |
| return type(obj)(conv) if isinstance(obj, tuple) else conv | |
| return obj | |
| def broadcast_raw( | |
| raw: Dict[str, Any], | |
| src_rank: int = 0, | |
| device: Optional[torch.device] = None, | |
| ) -> Dict[str, Any]: | |
| """Broadcast a ``raw`` dict (mix of tensors, strings, ints, nested | |
| dicts) from ``src_rank`` to all ranks. Required when paired with | |
| ``rank0_only=True`` sampling: every rank must train on the SAME | |
| ``(raw, completion)`` pair or the student/teacher forward sees | |
| mismatched (question, answer) and the distillation loss becomes | |
| meaningless. | |
| This effectively reduces the per-step effective batch size by | |
| ``world_size`` because every rank processes the same sample; callers | |
| should increase ``gradient_accumulation_steps`` accordingly to keep | |
| the tokens-per-optimizer-step budget constant. | |
| Large image tensors are round-tripped through pickle (CPU) which is | |
| not the most efficient path, but the OPSD batch is b=1 and the | |
| problem image is a few MB, so the wall-clock cost is negligible | |
| relative to the ~O(seconds) saved vs. FSDP summon on every rank. | |
| """ | |
| if not dist.is_available() or not dist.is_initialized(): | |
| return raw | |
| world = dist.get_world_size() | |
| if world <= 1: | |
| return raw | |
| my_rank = dist.get_rank() | |
| if my_rank == src_rank: | |
| payload: List[Any] = [_to_cpu_picklable(raw)] | |
| else: | |
| payload = [None] | |
| dist.broadcast_object_list(payload, src=src_rank) | |
| received = payload[0] if payload[0] is not None else {} | |
| if device is not None: | |
| received = _to_device(received, device) | |
| return received | |
Xet Storage Details
- Size:
- 33.3 kB
- Xet hash:
- 572ec5b2dcd88ca92797cdf9a33f248bc017be0d16e4a30afa1b07c17c20c336
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.