Files
wehub-resource-sync a203934033
Lint test / lint (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:34:58 +08:00

2201 lines
86 KiB
Python

# Copyright (c) ModelScope Contributors. All rights reserved.
import datasets
import functools
import ipaddress
import math
import os
import re
import socket
import time
import torch
import torch.nn.functional as F
from contextlib import contextmanager, nullcontext
from dataclasses import asdict
from datetime import timedelta
from functools import partial
from io import BytesIO
from msgspec import field
from packaging import version
from peft.tuners.lora import LoraLayer
from PIL import Image
from pydantic import BaseModel, field_validator
from torch import nn
from torch.utils.data import DataLoader, RandomSampler
from transformers.utils import is_torch_npu_available
from types import MethodType
from typing import Any, Dict, Iterable, List, Optional, Tuple, TypeVar, Union
from swift.rl_core.data import GRPOBatch, OnPolicySample
from swift.template import Messages, Template
from swift.tuners.lora import LoraConfig
from swift.utils import (gc_collect, get_cu_seqlens_from_position_ids, get_logger, get_packed_seq_params,
get_torch_device, is_swanlab_available, is_vllm_available, is_wandb_available, swanlab_get_run,
synchronize, to_device)
if is_wandb_available():
import wandb
if is_swanlab_available():
import swanlab
T = TypeVar('T')
_ipv6_patch_applied = False
# Constants for the RL training LoRA adapter identity.
VLLM_LORA_INT_ID = 111
VLLM_LORA_NAME = 'swift_lora'
VLLM_LORA_PATH = 'swift_dummy_lora_path'
def broadcast_tensor_for_vllm_weight_sync(communicator, tensor: torch.Tensor, src: int) -> None:
if is_torch_npu_available():
device_module = get_torch_device()
with device_module.device(communicator.device):
communicator.broadcast(tensor, src=src, stream=device_module.current_stream())
else:
communicator.broadcast(tensor, src=src, stream=getattr(get_torch_device(), 'current_stream', lambda: None)())
if is_vllm_available():
from vllm.lora.request import LoRARequest
class TensorLoRARequest(LoRARequest):
peft_config: dict = field(default=None)
lora_tensors: dict = field(default=None)
lora_embeddings: Optional[Dict[str, torch.Tensor]] = None
@property
def config(self):
return self.peft_config
@property
def embeddings(self):
return self.lora_embeddings
else:
TensorLoRARequest = None
def chunk_list(lst: list, n: int) -> list[list]:
"""
Split list `lst` into `n` evenly distributed sublists.
Example:
```python
>>> chunk_list([1, 2, 3, 4, 5, 6], 2)
[[1, 2, 3], [4, 5, 6]]
>>> chunk_list([1, 2, 3, 4, 5, 6], 4)
[[1, 2], [3, 4], [5], [6]]
>>> chunk_list([1, 2, 3, 4, 5, 6], 8)
[[1], [2], [3], [4], [5], [6], [], []]
```
"""
k, r = divmod(len(lst), n)
return [lst[i * k + min(i, r):(i + 1) * k + min(i + 1, r)] for i in range(n)]
def is_valid_ipv6_address(address: str) -> bool:
"""Check if the given address is a valid IPv6 address."""
try:
ipaddress.IPv6Address(address)
return True
except ValueError:
return False
def format_host_for_url(host: str) -> str:
"""Format host for URL - wrap IPv6 addresses in brackets."""
if is_valid_ipv6_address(host):
return f'[{host}]'
return host
def resolve_hostname(hostname: str) -> str:
"""Resolve hostname to IP address, supporting both IPv4 and IPv6.
Uses socket.getaddrinfo() which supports both IPv4 and IPv6,
unlike socket.gethostbyname() which only supports IPv4.
"""
# If it's already an IP address (IPv4 or IPv6), return as-is
try:
ipaddress.ip_address(hostname)
return hostname
except ValueError:
pass
# Resolve hostname using getaddrinfo (supports both IPv4 and IPv6)
try:
addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
if addr_info:
# Return the first resolved address
return addr_info[0][4][0]
except socket.gaierror:
pass
# Fallback to original hostname if resolution fails
return hostname
def patch_stateless_process_group_for_ipv6():
"""Apply monkey patch to vLLM's StatelessProcessGroup.create to support IPv6.
The original implementation hardcodes socket.AF_INET which only supports IPv4.
This patch detects IPv6 addresses at runtime and uses socket.AF_INET6 accordingly.
For IPv4 addresses, it falls back to the original implementation.
This function is idempotent - calling it multiple times is safe.
"""
global _ipv6_patch_applied
if _ipv6_patch_applied:
return
if not is_vllm_available():
return
import inspect
from vllm.distributed.utils import StatelessProcessGroup
# vLLM >= 0.19.0: create() accepts listen_socket and handles TCPStore internally
_has_listen_socket_param = 'listen_socket' in inspect.signature(StatelessProcessGroup.create).parameters
# Save original method for fallback
_original_create = StatelessProcessGroup.create
@staticmethod
def _patched_stateless_pg_create(
host: str,
port: int,
rank: int,
world_size: int,
data_expiration_seconds: int = 3600,
store_timeout: int = 300,
**kwargs,
) -> StatelessProcessGroup:
"""Patched version of StatelessProcessGroup.create that supports IPv6.
For IPv4 addresses, falls back to the original implementation.
"""
# If not IPv6, use original implementation
if not is_valid_ipv6_address(host):
return _original_create(
host=host,
port=port,
rank=rank,
world_size=world_size,
data_expiration_seconds=data_expiration_seconds,
store_timeout=store_timeout,
**kwargs,
)
# IPv6 path: create an AF_INET6 socket
launch_server = rank == 0
if launch_server:
listen_socket = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listen_socket.bind((host, port))
listen_socket.listen()
else:
listen_socket = None
if _has_listen_socket_param:
# vLLM >= 0.19.0: pass listen_socket to create(), which handles
# TCPStore creation and returns StatelessProcessGroup without socket field
kwargs.pop('listen_socket', None)
return _original_create(
host=host,
port=port,
rank=rank,
world_size=world_size,
data_expiration_seconds=data_expiration_seconds,
store_timeout=store_timeout,
listen_socket=listen_socket,
**kwargs,
)
else:
# vLLM < 0.19.0: manually create TCPStore and pass socket to constructor
from torch.distributed import TCPStore
listen_fd = listen_socket.fileno() if listen_socket else None
store = TCPStore(
host_name=host,
port=port,
world_size=world_size,
is_master=launch_server,
timeout=timedelta(seconds=store_timeout),
use_libuv=False,
master_listen_fd=listen_fd,
)
return StatelessProcessGroup(
rank=rank,
world_size=world_size,
store=store,
socket=listen_socket,
data_expiration_seconds=data_expiration_seconds,
)
# Apply the monkey patch to vLLM
StatelessProcessGroup.create = _patched_stateless_pg_create
_ipv6_patch_applied = True
# Apply IPv6 patch at module load time
patch_stateless_process_group_for_ipv6()
# code borrowed from verl/verl/utils/memory_utils.py
def aggressive_empty_cache(force_sync: bool = True, max_retries: int = 3) -> None:
"""
More aggressive GPU memory cleanup function, tries to release PyTorch reserved
but unallocated memory.
Args:
force_sync: Whether to force device synchronization
max_retries: Maximum number of retries
"""
logger = get_logger()
device = get_torch_device()
if not hasattr(device, 'is_available') or not device.is_available():
return
for attempt in range(max_retries):
# Record memory status before cleanup
before_reserved = device.memory_reserved()
before_allocated = device.memory_allocated()
# Run garbage collection
gc_collect()
# Clear PyTorch cache
device.empty_cache()
# Force synchronization (optional)
if force_sync:
device.synchronize()
# Record memory status after cleanup
after_reserved = device.memory_reserved()
after_allocated = device.memory_allocated()
# Calculate freed memory
reserved_freed = before_reserved - after_reserved
allocated_freed = before_allocated - after_allocated
logger.info(f'Memory cleanup attempt {attempt + 1}: Freed {reserved_freed / 1024**3:.2f} GB reserved, '
f'{allocated_freed / 1024**3:.2f} GB allocated')
# Stop retrying if little memory was freed
if reserved_freed < 1024**3: # less than 1GB
break
def prepare_deepspeed(model, accelerator, deepspeed_config=None, deepspeed_plugin=None, training_args=None):
"""
Prepares the model for DeepSpeed inference or evaluation by initializing it with the appropriate configuration.
Args:
model: The model to prepare
accelerator: The accelerator instance
deepspeed_config: Optional deepspeed config. If provided, use this instead of accelerator's plugin.
deepspeed_plugin: Optional DeepSpeedPlugin. If provided, use this instead of accelerator's plugin.
training_args: Optional training arguments for resolving "auto" values in config
Returns:
The prepared DeepSpeed model
"""
try:
import deepspeed
import os
from accelerate.utils import DeepSpeedPlugin
from copy import deepcopy
from transformers.integrations.deepspeed import HfTrainerDeepSpeedConfig
except ImportError:
pass
# Determine which config to use and create HfTrainerDeepSpeedConfig
if deepspeed_config is not None:
# Use provided config - need to wrap it with HfTrainerDeepSpeedConfig to handle "auto" values
if isinstance(deepspeed_config, dict):
# Create HfTrainerDeepSpeedConfig which will handle "auto" values
hf_ds_config = HfTrainerDeepSpeedConfig(deepspeed_config)
# Process the config with training args to resolve "auto" values
if training_args is not None:
hf_ds_config.trainer_config_process(training_args)
# Create a DeepSpeedPlugin with the processed config
temp_plugin = DeepSpeedPlugin(hf_ds_config=hf_ds_config)
config_kwargs = deepcopy(temp_plugin.deepspeed_config)
else:
raise ValueError(f'deepspeed_config should be a dict, got {type(deepspeed_config)}')
elif deepspeed_plugin is not None:
# Use provided plugin
config_kwargs = deepcopy(deepspeed_plugin.deepspeed_config)
else:
# Use accelerator's plugin (default behavior)
deepspeed_plugin = accelerator.state.deepspeed_plugin
config_kwargs = deepcopy(deepspeed_plugin.deepspeed_config)
stage = config_kwargs['zero_optimization']['stage']
if model is not None:
hidden_size = (
max(model.config.hidden_sizes) if getattr(model.config, 'hidden_sizes', None) else getattr(
model.config, 'hidden_size', None))
if hidden_size is not None and stage == 3:
# Note that `stage3_prefetch_bucket_size` can produce DeepSpeed messages like: `Invalidate trace cache
# @ step 0: expected module 1, but got module 0`
# This is expected and is not an error, see: https://github.com/microsoft/DeepSpeed/discussions/4081
config_kwargs.update({
'zero_optimization.reduce_bucket_size': hidden_size * hidden_size,
'zero_optimization.stage3_param_persistence_threshold': 10 * hidden_size,
'zero_optimization.stage3_prefetch_bucket_size': 0.9 * hidden_size * hidden_size,
})
# If ZeRO-3 is used, we shard both the active and reference model.
# Otherwise, we assume the reference model fits in memory and is initialized on each device with ZeRO
# disabled (stage 0)
if stage != 3:
config_kwargs['zero_optimization']['stage'] = 0
# CRITICAL: Save and clear DeepSpeed-related environment variables before initialization
# These environment variables (set during student model's DeepSpeed init) can override our config!
# Reference: https://github.com/microsoft/DeepSpeed/issues/xxxx
env_vars_to_clear = [
'DEEPSPEED_ZERO_STAGE',
'DEEPSPEED_CONFIG',
'DEEPSPEED_CONFIG_FILE',
]
saved_env = {}
for env_var in env_vars_to_clear:
if env_var in os.environ:
saved_env[env_var] = os.environ[env_var]
del os.environ[env_var]
try:
# Explicitly pass args=None to ensure no args.deepspeed_config interference
model, *_ = deepspeed.initialize(args=None, model=model, config=config_kwargs)
model.eval()
finally:
# Restore environment variables
for env_var, value in saved_env.items():
os.environ[env_var] = value
return model
@contextmanager
def memory_time_profiling_context(
name: str = 'Operation',
enable_profiling: bool = True,
sync_cuda: bool = True,
reset_peak_stats: bool = True,
):
"""
General-purpose memory and time profiling context manager (pure monitoring, no execution).
Records memory usage and execution time when entering and exiting the context, but does not
handle any actual model loading/offloading operations.
Args:
name: Operation name for logging identification
enable_profiling: Whether to enable profiling records
sync_cuda: Whether to synchronize CUDA before recording (ensures accuracy with slight overhead)
reset_peak_stats: Whether to reset peak memory statistics on exit
"""
if not enable_profiling:
yield
return
logger = get_logger()
# ===== Entry phase: Record initial state =====
if sync_cuda:
synchronize()
gc_collect()
# Record initial memory state
memory_before = torch.cuda.memory_allocated() / 1024**3 # GiB
memory_reserved_before = torch.cuda.memory_reserved() / 1024**3
max_memory_before = torch.cuda.max_memory_allocated() / 1024**3
logger.info(f'[{name}] Before: '
f'Allocated = {memory_before:.2f} GiB, '
f'Reserved = {memory_reserved_before:.2f} GiB, '
f'Peak = {max_memory_before:.2f} GiB')
# Start timing
start_time = time.perf_counter()
yield
# Synchronize and clean up memory before measuring (important for offload operations)
if sync_cuda:
synchronize()
gc_collect()
# ===== Exit phase: Record final state =====
# Calculate elapsed time (before cleanup to measure actual operation time)
elapsed_time = time.perf_counter() - start_time
# Record final memory state
memory_after = torch.cuda.memory_allocated() / 1024**3
memory_reserved_after = torch.cuda.memory_reserved() / 1024**3
peak_memory = torch.cuda.max_memory_allocated() / 1024**3
memory_change = memory_after - memory_before
logger.info(f'[{name}] After: '
f'Allocated = {memory_after:.2f} GiB, '
f'Reserved = {memory_reserved_after:.2f} GiB, '
f'Peak = {peak_memory:.2f} GiB, '
f'Change = {memory_change:+.2f} GiB, '
f'Time = {elapsed_time:.2f}s')
# Reset peak memory statistics for next cycle
if reset_peak_stats and torch.cuda.is_available():
torch.cuda.reset_peak_memory_stats()
def round_robin(num_reqs, num_workers):
"""Distribute requests evenly across workers using round-robin algorithm.
Args:
num_reqs (int): Total number of requests to distribute
num_workers (int): Number of available workers
Returns:
list: A list of lists where each sublist contains the request indices
assigned to that particular node
"""
distribution = [[] for _ in range(num_workers)]
for idx in range(num_reqs):
worker_id = idx % num_workers
distribution[worker_id].append(idx)
return distribution
@contextmanager
def patch_lora_merge(model, parameter_group=None):
"""Patch LoraLayer.merge to support selective merging by ``parameter_group``.
peft's ``merge_adapter()`` merges the whole model; this patch lets us merge only the
layers whose name is in ``parameter_group`` (used by the vLLM weight-sync, which merges
one parameter group at a time within its DeepSpeed Zero3 gather context).
Before merging, each target adapter's sublayers (lora_A/B, and the DoRA
``lora_magnitude_vector``) are aligned to the base-layer device via peft's
type-agnostic ``_move_adapter_to_device_of_base_layer``. This is correct for
Linear/Embedding/Conv as well as parameter-based ``ParamWrapper`` (MoE experts via
``target_parameters``), whose base layer has no ``.weight`` and which overrides the
method to use ``get_param().device``. This replaces the previous hand-rolled,
Linear-only device handling that hard-coded ``base_layer.weight.device``.
Args:
model: The PEFT model to patch
parameter_group: Optional list of parameter names to restrict merging
Yields:
The patched model (context manager ensures cleanup)
"""
from peft.tuners.tuners_utils import check_adapters_to_merge
def merge(self, safe_merge: bool = False, adapter_names: Optional[list[str]] = None) -> None:
if parameter_group and all(self.name not in pg for pg in parameter_group):
return # Skip if not in target parameter group
for active_adapter in check_adapters_to_merge(self, adapter_names) or []:
# Align adapter sublayers (lora_A/B, DoRA magnitude, ...) to the base device.
# Type-agnostic: ParamWrapper overrides this to use get_param().device.
self._move_adapter_to_device_of_base_layer(active_adapter)
return self.merge_origin(safe_merge, adapter_names)
# Patch all LoraLayer instances
for name, module in model.named_modules():
if isinstance(module, LoraLayer):
module.name = name
if not hasattr(module, 'merge_origin') and hasattr(module, 'base_layer'):
module.merge_origin = module.merge
module.merge = MethodType(merge, module)
try:
yield model
finally:
# Cleanup: restore original methods
for module in model.modules():
if isinstance(module, LoraLayer) and hasattr(module, 'merge_origin'):
module.merge = module.merge_origin
del module.merge_origin
@contextmanager
def patch_lora_unmerge(model):
"""Patch LoraLayer.unmerge to align adapter sublayers to the base device first.
Mirrors ``patch_lora_merge``'s device handling (via peft's type-agnostic
``_move_adapter_to_device_of_base_layer``) so unmerge works under DeepSpeed Zero3 /
offload and for parameter-based ``ParamWrapper`` layers.
"""
def unmerge_patched(self):
if not self.merged:
return
# Move adapter sublayers (incl. DoRA magnitude) to the base device first
for adapter in list(self.merged_adapters):
self._move_adapter_to_device_of_base_layer(adapter)
return self.unmerge_origin()
for module in model.modules():
if isinstance(module, LoraLayer) and not hasattr(module, 'unmerge_origin'):
module.unmerge_origin = module.unmerge
module.unmerge = MethodType(unmerge_patched, module)
try:
yield model
finally:
for module in model.modules():
if isinstance(module, LoraLayer) and hasattr(module, 'unmerge_origin'):
module.unmerge = module.unmerge_origin
del module.unmerge_origin
@contextmanager
def profiling_context(trainer, name: str):
start_time = time.perf_counter()
yield
end_time = time.perf_counter()
duration = end_time - start_time
if trainer is None:
return
profiling_metrics = {f'profiling/Time taken: {trainer.__class__.__name__}.{name}': duration}
is_main_process = False
if hasattr(trainer, 'accelerator'):
is_main_process = trainer.accelerator.is_main_process
elif hasattr(trainer, 'is_main_process'):
is_main_process = trainer.is_main_process
if 'wandb' in trainer.args.report_to and wandb.run is not None and is_main_process:
wandb.log(profiling_metrics, commit=False)
if 'swanlab' in trainer.args.report_to and swanlab_get_run() is not None and is_main_process:
swanlab.log(profiling_metrics)
def profiling_decorator(func):
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
with profiling_context(self, func.__name__):
return func(self, *args, **kwargs)
return wrapper
class _ForwardRedirection:
"""Implements the `forward-redirection`.
Taken from Pytorch-lightning:
https://github.com/Lightning-AI/pytorch-lightning/blob/02311d03fb982560246eead7c08104481fac9579/src/lightning/pytorch/strategies/strategy.py#L602
A method call to a wrapped module gets rerouted through the wrapper's `forward` method instead.
"""
def __call__(self, wrapper_module: nn.Module, original_module: nn.Module, method: callable, *args: Any,
**kwargs: Any):
"""Reroutes a method call through the `wrapper_module`'s `forward` method.
Args:
wrapper_module: The module that has `original_module` wrapped.
original_module: The module that was wrapped inside `wrapper_module`.
method_name: The name of the method that should be called on the `original_module` after inputs get
redirected through the `wrapper_module`'s `forward` method.
*args: The positional arguments to the method `method_name`. They will get passed to a patched
`forward` method instead.
**kwargs: The keyword arguments to the method `method_name`. They will get passed to a patched
`forward` method instead.
"""
original_forward = original_module.forward
def wrapped_forward(*_args: Any, **_kwargs: Any) -> Any:
# Unpatch ourselves immediately before calling the method `method_name`
# because itself may want to call the real `forward`
original_module.forward = original_forward # type: ignore[method-assign]
# Call the actual method e.g. `.training_step(...)`
out = method(*_args, **_kwargs)
self.on_after_inner_forward(wrapper_module, original_module)
return out
# Patch the original_module's forward so we can redirect the arguments back to the real method
original_module.forward = wrapped_forward # type: ignore[method-assign]
wrapper_output = wrapper_module(*args, **kwargs)
self.on_after_outer_forward(wrapper_module, original_module)
return wrapper_output
def on_after_inner_forward(self, wrapper_module: nn.Module, original_module: nn.Module) -> None:
pass
def on_after_outer_forward(self, wrapper_module: nn.Module, original_module: nn.Module) -> None:
pass
def entropy_from_logits(logits, chunk_size: int = 1) -> torch.Tensor:
"""
Compute the Shannon entropy (in nats) for each row of *logits* without
materialising the full soft-max in memory.
The batch dimension is processed in chunks of size `chunk_size` so that
only a subset of rows is expanded to probabilities at any one time.
Args:
logits (`torch.Tensor`):
Logits tensor of shape `(..., num_classes)`. Entropy is taken along the last axis; all
leading dimensions are preserved.
chunk_size (`int`, *optional*, defaults to `1`):
Number of rows to process per iteration.
Returns:
`torch.Tensor`:
Entropy values with shape `logits.shape[:-1]`.
"""
per_token_entropies = []
for logits_chunk in logits.split(chunk_size, dim=0):
logps = F.log_softmax(logits_chunk, dim=-1)
chunk_entropy = -(torch.exp(logps) * logps).sum(-1)
per_token_entropies.append(chunk_entropy)
return torch.cat(per_token_entropies, dim=0)
def load_pil_img(img) -> Image:
if isinstance(img, (list, tuple)):
if len(img) == 1:
img = img[0]
else:
raise ValueError('Image list must contain a single image.')
if isinstance(img, Image.Image):
return img
if isinstance(img, str):
return Image.open(img)
if not isinstance(img, dict):
raise ValueError("Image must be a PIL Image, a file path, or a dictionary with 'bytes' or 'path' key.")
if 'bytes' in img and img['bytes'] is not None:
return Image.open(BytesIO(img['bytes']))
elif 'path' in img and img['path'] is not None:
return Image.open(img['path'])
else:
raise ValueError("Image dictionary must contain either 'bytes' or 'path' key.")
def get_response_prefix_ids(template: Template, sample_enable_thinking: Optional[bool] = None) -> Optional[List[int]]:
effective = sample_enable_thinking if sample_enable_thinking is not None else template.enable_thinking
if effective is True:
prefix_str = template.template_meta.thinking_prefix
elif effective is False:
prefix_str = template.template_meta.non_thinking_prefix
else:
return None
if prefix_str:
return template.tokenizer.encode(prefix_str, add_special_tokens=False)
return None
def encode_sample(sample: OnPolicySample, template: Template, *, encode_prompt_only: bool = False) -> Dict[str, Any]:
"""Encode a sample into a template.encode output dict.
Does NOT mutate ``sample.messages`` — works on a copy from
``to_template_dict()`` so the sample's original messages are preserved
for logging / reward computation / reuse across steps_per_generation.
Per-sample ``enable_thinking``: the response prefix (thinking or
non-thinking) is computed per-sample from
``sample.extra['chat_template_kwargs']['enable_thinking']``, falling back
to the template's global setting. This keeps the trainer sequence
aligned with the rollout sequence for both thinking and non-thinking
prefixes.
"""
data = sample.to_template_dict()
if sample.response_token_ids:
loss_mask = sample.response_loss_mask or None
msgs = data.get('messages')
if msgs is not None:
msgs = [m.copy() for m in msgs]
ctk = sample.extra.get('chat_template_kwargs') or {}
sample_et = ctk.get('enable_thinking')
prefix_ids = get_response_prefix_ids(template, sample_enable_thinking=sample_et)
data['messages'] = replace_assistant_response_with_ids(
msgs, sample.response_token_ids, loss_mask, non_thinking_prefix_ids=prefix_ids)
if encode_prompt_only:
messages = data.get('messages', [])
if messages and messages[-1].get('role') == 'assistant':
data = {**data, 'messages': messages[:-1] + [{**messages[-1], 'content': None}]}
encoded = template.encode(data, return_length=True)
return encoded
def replace_assistant_response_with_ids(messages: 'Messages',
completion_ids: List[Union[int, List[int]]],
loss_mask: Optional[List[List[int]]] = None,
non_thinking_prefix_ids: Optional[List[int]] = None) -> 'Messages': # noqa
"""
Replace assistant messages in a conversation with token IDs (and optional loss masks).
This function traverses the messages in reverse order and replaces the content of
assistant-role messages with the given `completion_ids`. If `loss_mask` is provided,
each assistant message content will be replaced by a dictionary containing both the
token IDs and the corresponding loss mask.
Args:
messages:
List of message dictionaries representing a conversation history.
completion_ids:
Either:
- A single list of token IDs, e.g. [1, 2, 3]
- A list of completion sequences, e.g. [[1, 2], [3, 4]]
loss_mask (optional):
Loss mask(s) aligned with `completion_ids`.
Must satisfy:
- Same outer length as `completion_ids`
- Each inner list has the same length as the corresponding completion_ids sequence
Example:
completion_ids = [[1, 2], [3, 4]]
loss_mask = [[1, 1], [1, 0]]
Returns:
The modified messages list, where assistant responses are replaced by:
- A list of token IDs if `loss_mask` is None
- A dict with keys:
- "input_ids": List[int]
- "loss_scale": List[int]
if `loss_mask` is provided.
Example:
>>> messages = [
... {"role": "user", "content": "Hello"},
... {"role": "assistant", "content": "Hi there"}
... ]
>>> replace_assistant_response_with_ids(messages, [1, 2, 3])
[{'role': 'user', 'content': 'Hello'},
{'role': 'assistant', 'content': [1, 2, 3]}]
>>> replace_assistant_response_with_ids(messages,
... completion_ids=[[1, 2, 3]],
... loss_mask=[[1, 1, 0]])
[{'role': 'user', 'content': 'Hello'},
{'role': 'assistant', 'content': {'input_ids': [1, 2, 3], 'loss_scale': [1, 1, 0]}}]
"""
# Normalize input to always be list of lists
if isinstance(completion_ids[0], int):
completion_ids = [completion_ids]
if loss_mask and isinstance(loss_mask[0], int):
loss_mask = [loss_mask]
# Inject the non-thinking prefix (e.g. '<think>\n\n</think>\n\n') into the LAST assistant turn.
# When enable_thinking false, the engine prepends non_thinking_prefix before generation
# so completion_ids here are generated with the non-thinking prefix, inject here
if non_thinking_prefix_ids:
n_prefix = len(non_thinking_prefix_ids)
last_ids = list(completion_ids[-1])
# Skip if the response already starts with the prefix (avoid double injection).
if last_ids[:n_prefix] != list(non_thinking_prefix_ids):
if loss_mask is None:
loss_mask = [[1] * len(ids) for ids in completion_ids]
completion_ids[-1] = list(non_thinking_prefix_ids) + last_ids
loss_mask[-1] = [0] * n_prefix + list(loss_mask[-1])
if loss_mask:
assert (
len(completion_ids) == len(loss_mask)
and all(len(ids) == len(mask) for ids, mask in zip(completion_ids, loss_mask))
), f'completion_ids and loss_mask must have the same length, but got {len(completion_ids)} and {len(loss_mask)}'
remaining_completions = len(completion_ids)
completion_index = 0
for message in reversed(messages):
if message['role'] != 'assistant':
continue
if completion_index >= remaining_completions:
break
# Assign completion IDs (starting from last)
if loss_mask:
message['content'] = {
'loss_scale': loss_mask[-1 - completion_index],
'token_ids': completion_ids[-1 - completion_index]
}
else:
message['content'] = completion_ids[-1 - completion_index]
completion_index += 1
return messages
def parse_prompt_logprobs(response, topk: int) -> Tuple[List[List[float]], List[List[int]]]:
"""Parse vLLM prompt_logprobs into per-position (logprobs, token_ids).
vLLM's ``prompt_logprobs[i]`` is ``{token_id: {logprob, rank, ...}}`` for predicting
prompt token ``i`` (position 0 is ``None`` and skipped).
- ``topk == 0`` (sampled-token, OPD-RL): the teacher was queried with
``prompt_logprobs=0``, so each position holds exactly the *sampled* (actually-present)
token — take that single entry. This is token-in-token-out, NOT the top-1: the
sampled token may have any rank.
- ``topk > 0`` (top-k, GKD): the ``topk`` highest-probability tokens, ordered by logprob
(== rank order). The sampled token, if returned as an extra ``k+1``-th entry, is dropped.
"""
raw = response.prompt_logprobs or []
lps: List[List[float]] = []
ixs: List[List[int]] = []
for pos_lp in raw[1:]:
if topk == 0:
# prompt_logprobs=0 -> a single entry: the sampled (actually-present) token.
tid, info = next(iter(pos_lp.items()))
lps.append([info['logprob']])
ixs.append([int(tid)])
else:
items = sorted(pos_lp.items(), key=lambda x: -x[1]['logprob'])[:topk]
lps.append([info['logprob'] for _, info in items])
ixs.append([int(tid) for tid, _ in items])
return lps, ixs
def assemble_teacher_topk_logprobs(
parsed: List[Tuple[List[List[float]], List[List[int]]]],
batch_size: int,
seq_len: int,
cu_seqlens: Optional[List[int]],
topk: int,
device: torch.device,
offsets: Optional[List[int]] = None,
) -> Tuple[torch.Tensor, torch.Tensor]:
is_packed = cu_seqlens is not None
if is_packed:
total_len = seq_len
out_lp = torch.full((total_len, topk), float('-inf'), dtype=torch.float32)
out_ix = torch.zeros(total_len, topk, dtype=torch.long)
num_seqs = len(cu_seqlens) - 1
assert len(parsed) == num_seqs, f'parsed length {len(parsed)} != num_seqs {num_seqs}'
for i in range(num_seqs):
start, end = cu_seqlens[i], cu_seqlens[i + 1]
lps, ixs = parsed[i]
length = min(len(lps), end - start)
if length <= 0:
continue
out_lp[start:start + length] = torch.tensor(lps[:length], dtype=torch.float32)
out_ix[start:start + length] = torch.tensor(ixs[:length], dtype=torch.long)
return out_lp.unsqueeze(0).to(device), out_ix.unsqueeze(0).to(device)
out_lp = torch.full((batch_size, seq_len, topk), float('-inf'), dtype=torch.float32)
out_ix = torch.zeros(batch_size, seq_len, topk, dtype=torch.long)
assert len(parsed) == batch_size, f'parsed length {len(parsed)} != batch_size {batch_size}'
for idx in range(batch_size):
lps, ixs = parsed[idx]
P = len(lps)
start = offsets[idx] if offsets is not None else 0
length = min(P, seq_len - start)
if length <= 0:
continue
out_lp[idx, start:start + length] = torch.tensor(lps[:length], dtype=torch.float32)
out_ix[idx, start:start + length] = torch.tensor(ixs[:length], dtype=torch.long)
return out_lp.to(device), out_ix.to(device)
def patch_save_last_checkpoint():
import trl
if version.parse(trl.__version__) >= version.parse('0.20'):
return
# patch to fix save last_checkpoint https://github.com/modelscope/ms-swift/pull/4969
from trl.trainer.grpo_trainer import RepeatSampler
if not hasattr(RepeatSampler, 'old_len_func'):
origin_len_func = RepeatSampler.__len__
def patched_len(self) -> int:
return (self.num_samples // self.batch_size) * self.batch_size * self.mini_repeat_count * self.repeat_count
RepeatSampler.__len__ = patched_len
RepeatSampler.old_len_func = origin_len_func
def get_gather_if_zero3_context(trainer, is_zero3: Optional[bool] = None):
deepspeed_plugin = trainer.accelerator.state.deepspeed_plugin
zero_stage_3 = is_zero3 if is_zero3 is not None else (deepspeed_plugin is not None
and deepspeed_plugin.zero_stage == 3)
if zero_stage_3:
import deepspeed
gather_if_zero3 = deepspeed.zero.GatheredParameters
else:
gather_if_zero3 = nullcontext
return gather_if_zero3
def prepare_fsdp(model, accelerator, evaluation_mode: bool = True):
"""Prepare a model with FSDP wrapping
This function wraps a model with the appropriate FSDP mechanism based on
the accelerator configuration. It's designed for auxiliary models like
ref_model, teacher_model, or reward_model that need to be FSDP-wrapped
to prevent mixing DTensor (main model) with regular Tensor (auxiliary model).
Args:
model: The model to wrap with FSDP.
accelerator: The accelerator instance from trainer.
evaluation_mode: Whether to set the model to evaluation mode. Defaults to True.
When True, the model is frozen BEFORE FSDP wrapping to avoid float32 upcast,
which saves significant memory for evaluation-only models.
Returns:
The FSDP-wrapped model.
"""
if evaluation_mode:
model.eval()
for param in model.parameters():
param.requires_grad_(False)
if getattr(accelerator, 'is_fsdp2', False):
# FSDP2 uses fully_shard API with DTensor
from accelerate.utils.fsdp_utils import fsdp2_prepare_model
model = fsdp2_prepare_model(accelerator, model)
else:
# FSDP1 uses FullyShardedDataParallel wrapper
from trl.models.utils import prepare_fsdp as trl_prepare_fsdp
model = trl_prepare_fsdp(model, accelerator)
return model
_moe_model_registry_cache = None
def _get_moe_model_registry():
global _moe_model_registry_cache
if _moe_model_registry_cache is not None:
return _moe_model_registry_cache
import importlib
moe_model_configs = [
('vllm.model_executor.models.deepseek_v2', ('DeepseekV2ForCausalLM', 'DeepseekV3ForCausalLM'), 'mlp'),
('vllm.model_executor.models.mixtral', ('MixtralForCausalLM', ), 'block_sparse_moe'),
('vllm.model_executor.models.qwen2_moe', ('Qwen2MoeForCausalLM', ), 'mlp'),
('vllm.model_executor.models.qwen3_moe', ('Qwen3MoeForCausalLM', ), 'mlp'),
('vllm.model_executor.models.qwen3_vl_moe', ('Qwen3MoeLLMForCausalLM', ), 'mlp'),
('vllm.model_executor.models.qwen3_5', ('Qwen3_5MoeForCausalLM', ), 'mlp'),
('vllm.model_executor.models.qwen3_next', ('Qwen3NextForCausalLM', ), 'mlp'),
('vllm.model_executor.models.kimi_vl', ('KimiVLForConditionalGeneration', ), 'mlp'),
]
supported_moe_models = []
mlp_attr_mapping = {}
for module_path, class_names, mlp_attr in moe_model_configs:
try:
module = importlib.import_module(module_path)
for class_name in class_names:
if hasattr(module, class_name):
model_class = getattr(module, class_name)
supported_moe_models.append(model_class)
mlp_attr_mapping[model_class] = mlp_attr
except (ImportError, AttributeError, RuntimeError):
pass
_moe_model_registry_cache = (supported_moe_models, mlp_attr_mapping)
return _moe_model_registry_cache
def patch_vllm_moe_model_weight_loader(model):
"""
Patch vLLM MoE model to add weight_loader attribute to expert weights.
This is a workaround for a bug in vLLM 0.8.2 where MoE weights (w13_weight, w2_weight)
don't have the weight_loader attribute, causing AttributeError during weight loading.
Code adapted from verl/verl/utils/vllm/patch.py
Args:
model: The vLLM model to patch.
"""
# Check if already patched (idempotent). On NPU/vLLM-Ascend, sleep/wake
# and full-model reload can recreate expert Parameters while keeping this
# model-level flag, so the loader needs to be reattached every reload.
if getattr(model, '_swift_moe_weight_loader_patched', False) and not is_torch_npu_available():
return
supported_moe_models, mlp_attr_mapping = _get_moe_model_registry()
if not supported_moe_models:
return
original_model = model
original_model_type = type(model)
# Handle NPU ACLGraphWrapper (for vllm_ascend compatibility)
if hasattr(model, 'runnable') and 'ACLGraphWrapper' in str(original_model_type):
model = model.runnable
original_model_type = type(model)
# Get inner model (either model.model or model.language_model)
inner_model = getattr(model, 'model', None) or getattr(model, 'language_model', None)
if inner_model is None:
# Model structure not recognized, skip patching
return
if not isinstance(model, tuple(supported_moe_models)) and not isinstance(inner_model, tuple(supported_moe_models)):
return
# Handle Qwen3-VL MoE structure
if type(inner_model).__name__ == 'Qwen3MoeLLMForCausalLM':
inner_model = inner_model.model
if type(inner_model).__name__ == 'Qwen3_5MoeForCausalLM':
inner_model = inner_model.model
# Check if inner_model has layers attribute
if not hasattr(inner_model, 'layers'):
return
def maybe_patch_vllm_ascend_moe_expert_weight_loader(experts, name, param):
quant_method = getattr(experts, 'quant_method', None)
if not is_torch_npu_available() or not type(quant_method).__module__.startswith('vllm_ascend'):
return
from swift.model.npu_patch.vllm_ascend import (patch_vllm_ascend_moe_expert_weight_loader,
use_vllm_ascend_moe_preprocessed_weight)
patch_vllm_ascend_moe_expert_weight_loader(
experts,
name,
param,
load_preprocessed_weight=use_vllm_ascend_moe_preprocessed_weight(original_model),
)
for layer in inner_model.layers:
mlp_attr = mlp_attr_mapping.get(original_model_type, 'mlp')
mlp = getattr(layer, mlp_attr, None)
if not mlp:
continue
experts = getattr(mlp, 'experts', None)
if not experts or not hasattr(experts, 'weight_loader'):
continue
# Patch the weight loaders for MoE expert weights
for name, param in mlp.named_parameters():
if 'w13_weight' in name or 'w2_weight' in name:
if not hasattr(param, 'weight_loader'):
param.weight_loader = experts.weight_loader
maybe_patch_vllm_ascend_moe_expert_weight_loader(experts, name, param)
# Mark the model as patched (for idempotency)
original_model._swift_moe_weight_loader_patched = True
def finish_vllm_weight_reload(vllm_model, model_config, target_device):
if vllm_model is None or model_config is None or target_device is None:
return
if is_torch_npu_available():
from swift.model.npu_patch.vllm_ascend import should_skip_vllm_ascend_moe_post_load
if should_skip_vllm_ascend_moe_post_load(vllm_model):
return
try:
from vllm.model_executor.model_loader.utils import process_weights_after_loading
process_weights_after_loading(vllm_model, model_config, target_device)
except Exception:
return
_cached_reverse_renamings = None
def _build_reverse_renamings(model):
"""Build and cache reverse WeightRenaming rules for the given model.
Only one model type goes through weight sync per training run, so a single
module-level variable suffices. Returns None if no renamings apply.
"""
global _cached_reverse_renamings
if _cached_reverse_renamings is not None:
return _cached_reverse_renamings
try:
from transformers.core_model_loading import WeightRenaming
except ImportError:
return None
weight_conversions = getattr(model, '_weight_conversions', None)
if weight_conversions is None:
try:
from transformers.conversion_mapping import get_model_conversion_mapping
weight_conversions = get_model_conversion_mapping(model, add_legacy=False)
except Exception:
return None
if not weight_conversions:
return None
renamings = [c for c in weight_conversions if isinstance(c, WeightRenaming)]
if not renamings:
return None
# Reverse order before inverting, matching transformers' own revert_weight_conversion
# (core_model_loading.py) which reverses the list so that chained renamings undo
# in the correct order.
try:
_cached_reverse_renamings = [c.reverse_transform() for c in renamings[::-1]]
except Exception as e:
logger = get_logger()
logger.warning(f'Failed to build reverse renamings for {type(model).__name__}: {e}')
return None
return _cached_reverse_renamings
def revert_runtime_names_to_checkpoint(model, state_dict):
"""Map HF runtime param names back to HF checkpoint names before vLLM weight sync.
transformers>=5 may rename checkpoint keys to different *runtime* module names
(e.g. gemma4_unified: checkpoint ``model.vision_embedder.*`` -> runtime
``model.embed_vision.*``). vLLM's ``hf_to_vllm_mapper`` is built around the
checkpoint names (the same path used by ``vllm serve``), so online weight sync
that sends runtime names can land on the wrong vLLM module and raise
"There is no module or parameter named ...".
We revert only the *renaming* part (``WeightRenaming``). Tensor-level
``WeightConverter`` ops (e.g. MoE fuse/split) are intentionally skipped and
left to the existing MoE weight-loader patch, which expects the fused runtime
layout.
Safe by construction: models whose vLLM mapper accepts runtime names also
carry the checkpoint-name rules (required by ``vllm serve``), so reverting to
checkpoint names still maps correctly. Any failure or absence of conversions
is a no-op that returns the input unchanged.
"""
# Unwrap PEFT to reach the underlying transformers model that holds conversions.
if hasattr(model, 'get_base_model'):
try:
model = model.get_base_model()
except Exception:
pass
reverse_renamings = _build_reverse_renamings(model)
if not reverse_renamings:
return state_dict
try:
from transformers.core_model_loading import rename_source_key
except ImportError:
return state_dict
new_state_dict = {}
for name, param in state_dict.items():
try:
new_name, _ = rename_source_key(name, reverse_renamings, [])
except Exception:
new_name = name
new_state_dict[new_name] = param
return new_state_dict
def patch_vllm_load_adapter():
from vllm.lora.worker_manager import LRUCacheWorkerLoRAManager
try:
from vllm.lora.models import LoRAModel
except ImportError:
# vllm >= 0.13 https://github.com/vllm-project/vllm/pull/30253
from vllm.lora.lora_model import LoRAModel
from vllm.lora.utils import get_adapter_absolute_path
try:
from vllm.transformers_utils.tokenizer_group import TokenizerGroup
except ImportError:
# removed in https://github.com/vllm-project/vllm/pull/24078
TokenizerGroup = None
def patched_load_adapter(self: LRUCacheWorkerLoRAManager, lora_request: TensorLoRARequest) -> LoRAModel:
"""
code borrowed from verl.utils.vllm.utils.py
based on vllm.lora.worker_manager.WorkerLoRAManager._load_adapter, support load adapter with lora tensors
Reason:
VLLM does not support adding LoRA from tensors directly. It only supports adding LoRA via file paths.
To synchronize the LoRA tensors of the actor model, we need to find a workaround to enable VLLM to
load memory-based LoRA tensors.
"""
try:
supported_lora_modules = self._adapter_manager.supported_lora_modules
packed_modules_mapping = self._adapter_manager.packed_modules_mapping
expected_lora_modules: list[str] = []
for module in supported_lora_modules:
if module in packed_modules_mapping:
expected_lora_modules.extend(packed_modules_mapping[module])
else:
expected_lora_modules.append(module)
expected_lora_modules = list(set(expected_lora_modules))
# this is the patch
lora_tensors = None
from vllm.lora.peft_helper import PEFTHelper
if isinstance(lora_request, TensorLoRARequest):
peft_config = lora_request.peft_config
lora_tensors = lora_request.lora_tensors
peft_helper = PEFTHelper.from_dict(peft_config)
else:
lora_path = get_adapter_absolute_path(lora_request.lora_path)
peft_helper = PEFTHelper.from_local_dir(lora_path, self.max_position_embeddings)
# Validates the LoRA configuration against requirements before
# loading weights, throwing an exception if validation fails.
peft_helper.validate_legal(self.lora_config)
# For some models like Qwen2VL, we need to use hf_to_vllm_mapper
# to ensure correct loading of lora weights.
model = self._adapter_manager.model
hf_to_vllm_mapper = getattr(model, 'hf_to_vllm_mapper', None)
lora_request_kwargs = {
'peft_helper': peft_helper,
'lora_model_id': lora_request.lora_int_id,
'device': 'cpu',
'dtype': self.lora_config.lora_dtype,
'weights_mapper': hf_to_vllm_mapper,
}
if hasattr(self, 'embedding_padding_modules'):
lora_request_kwargs['embedding_modules'] = self.embedding_modules
lora_request_kwargs['embedding_padding_modules'] = self.embedding_padding_modules
else:
lora_request_kwargs['model_vocab_size'] = self.vocab_size
if hasattr(self.lora_config, 'lora_extra_vocab_size'):
# lora_extra_vocab_size is removed in vllm >= 0.12
# https://github.com/vllm-project/vllm/issues/23474
lora_request_kwargs['target_embedding_padding'] = (
self.vocab_size + self.lora_config.lora_extra_vocab_size)
if isinstance(lora_request, TensorLoRARequest):
lora = self._lora_model_cls.from_lora_tensors(
tensors=lora_tensors,
**lora_request_kwargs,
)
else:
lora = self._lora_model_cls.from_local_checkpoint(
lora_path,
expected_lora_modules,
**lora_request_kwargs,
)
except Exception as e:
raise e
if hasattr(self.lora_config, 'lora_extra_vocab_size'):
if lora.extra_vocab_size > self.lora_config.lora_extra_vocab_size:
raise ValueError(f'LoRA added vocab size {lora.extra_vocab_size} is greater than '
f'lora_extra_vocab_size {self.lora_config.lora_extra_vocab_size}.')
return lora
def patched_get_lora_tokenizer(self: TokenizerGroup, lora_request: LoRARequest):
# since we pass dummy path, skip get tokenizer from path
return self.tokenizer
if not hasattr(LRUCacheWorkerLoRAManager, '_old_load_adapter'):
_old_load_adapter = LRUCacheWorkerLoRAManager._load_adapter
LRUCacheWorkerLoRAManager._load_adapter = patched_load_adapter
LRUCacheWorkerLoRAManager._old_load_adapter = _old_load_adapter
if TokenizerGroup is not None:
TokenizerGroup._old_get_lora_tokenizer = TokenizerGroup.get_lora_tokenizer
TokenizerGroup.get_lora_tokenizer = patched_get_lora_tokenizer
def expand_vllm_param_name_aliases(param_names: set[str]) -> set[str]:
stacked_mappings = [
(re.compile(r'\bqkv_proj\b'), ('q_proj', 'k_proj', 'v_proj', 'q', 'k', 'v')),
(re.compile(r'\bgate_up_proj\b'), ('gate_proj', 'up_proj')),
(re.compile(r'\bin_proj_ba\b'), ('in_proj_b', 'in_proj_a')),
(re.compile(r'\blanguage_model\.model\b'), ('model.language_model', )),
(re.compile(r'^visual\.'), ('model.visual.', )),
]
def _expand_once(keys: set[str]) -> set[str]:
expanded = set(keys)
for key in keys:
for pattern, aliases in stacked_mappings:
if pattern.search(key):
for alias in aliases:
expanded.add(pattern.sub(alias, key))
return expanded
# Two passes allow chained replacement:
# e.g. language_model.model + qkv_proj -> model.language_model + q_proj
expanded = _expand_once(param_names)
expanded = _expand_once(expanded)
return expanded
def add_base_layer_suffix_by_param_names(weight_iterator: Iterable[Tuple[str, Any]],
vllm_param_names: set[str]) -> Iterable[Tuple[str, Any]]:
"""Map HF dense param names to vLLM LoRA-wrapped modules (*.base_layer.weight / .bias)."""
for name, tensor in weight_iterator:
if '.base_layer.' in name or '.' not in name:
yield name, tensor
continue
if name in vllm_param_names:
yield name, tensor
continue
module_name, param_type = name.rsplit('.', 1)
if param_type in {'weight', 'bias'}:
bl = f'{module_name}.base_layer.{param_type}'
if bl in vllm_param_names:
name = bl
yield name, tensor
# FlattenedTensor, code borrowed from sglang/srt/weight_sync/tensor_bucket.py
class FlattenedTensorMetadata(BaseModel):
"""Metadata for a tensor in a flattened bucket"""
name: str
shape: Tuple[int, ...]
dtype: str
start_idx: int
end_idx: int
numel: int
@field_validator('dtype', mode='before')
@classmethod
def ensure_dtype_str(cls, v: Any) -> str:
# accept torch.dtype or str
if torch is not None and isinstance(v, torch.dtype):
return str(v)
if isinstance(v, str):
return v
raise ValueError('dtype must be a torch.dtype or str')
class TensorMetadata(BaseModel):
"""Metadata for a single tensor."""
name: str
shape: Tuple[int, ...]
dtype: str
numel: int
class UpdateFlattenedAdapterRequest(BaseModel):
peft_config: LoraConfig
metadatas: List[FlattenedTensorMetadata]
class UpdateFlattenedParamsRequest(BaseModel):
metadatas: List[FlattenedTensorMetadata]
class UpdateAdapterRequest(BaseModel):
"""Request for non-flattened adapter weight update"""
peft_config: LoraConfig
lora_tensors_metadata: List[TensorMetadata]
class FlattenedTensorBucket:
"""
A bucket that flattens multiple tensors into a single tensor for efficient processing
while preserving all metadata needed for reconstruction.
"""
def __init__(
self,
named_tensors: List[Tuple[str, torch.Tensor]] = None,
flattened_tensor: torch.Tensor = None,
metadata: List[FlattenedTensorMetadata] = None,
):
if named_tensors is not None:
if not named_tensors:
raise ValueError('Cannot create empty tensor bucket')
self.metadata: List[FlattenedTensorMetadata] = [None] * len(named_tensors)
flattened_chunks: List[torch.Tensor] = [None] * len(named_tensors)
current_byte = 0
for i, (name, tensor) in enumerate(named_tensors):
flat_u8 = tensor.flatten().view(torch.uint8)
flattened_chunks[i] = flat_u8
numel = flat_u8.numel()
self.metadata[i] = FlattenedTensorMetadata(
name=name,
shape=tuple(tensor.shape),
dtype=str(tensor.dtype),
start_idx=current_byte,
end_idx=current_byte + numel,
numel=numel,
)
current_byte += numel
self.flattened_tensor = torch.cat(flattened_chunks, dim=0)
else:
# Initialize from pre-flattened data
if flattened_tensor is None or metadata is None:
raise ValueError('Must provide either named_tensors or both flattened_tensor and metadata')
self.flattened_tensor = flattened_tensor
self.metadata = metadata
def get_flattened_tensor(self) -> torch.Tensor:
"""Get the flattened tensor containing all bucket tensors"""
return self.flattened_tensor
def get_metadata(self) -> List[FlattenedTensorMetadata]:
"""Get metadata for all tensors in the bucket"""
return self.metadata
def reconstruct_tensors(self) -> Dict[str, torch.Tensor]:
"""
Reconstruct original tensors from flattened tensor with optimized performance.
Uses memory-efficient operations to minimize allocations and copies.
"""
# preallocate the result list
reconstructed = {}
for meta in self.metadata:
dtype = getattr(torch, meta.dtype.split('.')[-1])
byte_slice = self.flattened_tensor[meta.start_idx:meta.end_idx]
tensor = byte_slice.view(dtype).reshape(meta.shape)
reconstructed[meta.name] = tensor
return reconstructed
def identity_data_collator(features, **kwargs):
"""Identity data collator that returns features as-is without any processing."""
return features
def mu_schedule_function(global_step: int, mu_warmup_steps: int, mu_decay_steps: int, mu_peak: float,
mu_valley: float) -> float:
"""
Computes a cosine decay schedule with a warmup phase for the mu parameter.
Args:
global_step: Current global training step
mu_warmup_steps: Number of warmup steps
mu_decay_steps: Number of decay steps
mu_peak: Peak value of mu during warmup
mu_valley: Final value of mu after decay
Returns:
Current mu value based on the schedule
"""
# Warmup
if global_step < mu_warmup_steps:
return (global_step / mu_warmup_steps) * mu_peak
# Decay
if global_step >= (mu_warmup_steps + mu_decay_steps):
return mu_valley
adjusted_step = global_step - mu_warmup_steps
cosine_decay = 0.5 * (1 + math.cos(math.pi * adjusted_step / mu_decay_steps))
decayed_mu = (mu_peak - mu_valley) * cosine_decay + mu_valley
return decayed_mu
def create_cyclic_iterator(iterable):
"""Create a cyclic iterator that repeats the iterable indefinitely."""
while True:
for x in iterable:
yield x
def get_chord_sft_dataloader(trainer,
dataset,
description,
batch_size,
sampler_fn=None,
is_training=False,
dataloader_key=None) -> DataLoader:
"""
Create a DataLoader from the given dataset for CHORD SFT training.
Mimics transformers.trainers._get_dataloader.
Args:
trainer: The trainer instance
dataset: The dataset to create DataLoader from
description: Description of the dataset (e.g., 'Training')
batch_size: Batch size for the DataLoader
sampler_fn: Optional sampler function
is_training: Whether this is for training
dataloader_key: Optional dataloader key
Returns:
Prepared DataLoader
"""
data_collator = identity_data_collator
if isinstance(dataset, datasets.Dataset):
dataset = trainer._remove_unused_columns(dataset, description=description)
else:
data_collator = trainer._get_collator_with_removed_columns(data_collator, description=description)
dataloader_params = {
'batch_size': batch_size,
'collate_fn': data_collator,
'num_workers': trainer.args.dataloader_num_workers,
'pin_memory': trainer.args.dataloader_pin_memory,
'persistent_workers': trainer.args.dataloader_persistent_workers,
}
if not isinstance(dataset, torch.utils.data.IterableDataset):
if sampler_fn is not None:
dataloader_params['sampler'] = sampler_fn(dataset)
dataloader_params['drop_last'] = trainer.args.dataloader_drop_last
dataloader_params['prefetch_factor'] = trainer.args.dataloader_prefetch_factor
if is_training:
from swift.utils import seed_worker
dataloader_params['worker_init_fn'] = partial(
seed_worker, num_workers=trainer.args.dataloader_num_workers, rank=trainer.args.process_index)
dataloader = trainer.accelerator.prepare(DataLoader(dataset, **dataloader_params))
return dataloader
def make_chord_sft_dataset(trainer, chord_sft_dataset):
"""
Create and setup CHORD SFT dataset iterator for the trainer.
Args:
trainer: The trainer instance
chord_sft_dataset: The CHORD SFT dataset
"""
trainer.chord_sft_dataset = chord_sft_dataset
if trainer.chord_sft_dataset:
chord_sft_dataloader = get_chord_sft_dataloader(
trainer=trainer,
dataset=chord_sft_dataset,
description='Training',
batch_size=trainer.args.chord_sft_per_device_train_batch_size,
sampler_fn=RandomSampler,
is_training=True,
)
return create_cyclic_iterator(chord_sft_dataloader)
def compute_chord_loss(trainer, grpo_loss: torch.Tensor) -> torch.Tensor:
"""
Compute CHORD loss combining GRPO loss with SFT loss.
Args:
trainer: The trainer instance
grpo_loss: The GRPO loss tensor
Returns:
Combined CHORD loss tensor
"""
from swift.trainers import per_token_loss_func
current_step = trainer.state.global_step
mu = mu_schedule_function(current_step, trainer.args.chord_mu_warmup_steps, trainer.args.chord_mu_decay_steps,
trainer.args.chord_mu_peak, trainer.args.chord_mu_valley)
chord_sft_loss = torch.tensor(0.0, device=grpo_loss.device, dtype=grpo_loss.dtype)
if mu > 0:
sft_inputs = next(trainer.chord_sft_iterator)
sft_inputs = to_device(sft_inputs, 'cpu')
sft_inputs = to_device(trainer.template.data_collator(sft_inputs), trainer.accelerator.device)
labels = sft_inputs.pop('labels')
loss_scale = sft_inputs.pop('loss_scale', None)
outputs = trainer.model(**sft_inputs)
chord_sft_loss = per_token_loss_func(outputs, labels)
if trainer.args.chord_enable_phi_function:
per_token_probs = torch.exp(-chord_sft_loss.detach())
phi = per_token_probs * (1 - per_token_probs)
chord_sft_loss *= phi
if loss_scale is not None:
loss_scale = torch.roll(loss_scale, shifts=-1, dims=-1).view(-1)
chord_sft_loss *= loss_scale
num_items_in_batch = (labels[:, 1:] != -100).sum()
chord_sft_loss = chord_sft_loss.sum() / num_items_in_batch
else:
assert mu == 0
chord_sft_loss = torch.tensor(0.0, device=grpo_loss.device, dtype=grpo_loss.dtype)
loss = (1 - mu) * grpo_loss + mu * chord_sft_loss
return loss
_EXPANDABLE_SEGMENTS_SET = 'expandable_segments' in os.environ.get('PYTORCH_CUDA_ALLOC_CONF', '')
def set_expandable_segments(enable: bool) -> None:
"""
Enable or disable expandable segments for CUDA memory allocation.
This function provides a safe way to configure CUDA expandable segments without
overriding user preferences. It only takes effect when the user has previously
set the PYTORCH_CUDA_ALLOC_CONF environment variable, ensuring that explicit
user configurations are respected.
Expandable segments allow PyTorch to grow memory pools dynamically, which can
help prevent out-of-memory (OOM) errors during long-running reinforcement
learning training sessions by reducing memory fragmentation.
Args:
enable (bool): Whether to enable expandable segments. When True, allows
CUDA memory pools to expand dynamically to reduce fragmentation and
mitigate OOM issues.
Note:
- Only takes effect if PYTORCH_CUDA_ALLOC_CONF was previously set by the user
- Requires CUDA to be available
- Changes apply to both the PyTorch allocator settings and environment variable
Example:
>>> # Only works if user has already set PYTORCH_CUDA_ALLOC_CONF
>>> set_expandable_segments(True) # Enable to help with OOM issues
>>> set_expandable_segments(False) # Disable for more predictable memory usage
"""
if not _EXPANDABLE_SEGMENTS_SET:
return
if torch.cuda.is_available():
torch.cuda.memory._set_allocator_settings(f'expandable_segments:{enable}')
os.environ['PYTORCH_CUDA_ALLOC_CONF'] = f'expandable_segments:{enable}'
def peft_config_to_dict(peft_config):
if not isinstance(peft_config, dict):
peft_config = asdict(peft_config)
# turn set to list to serializable
if 'target_modules' in peft_config and isinstance(peft_config['target_modules'], set):
peft_config['target_modules'] = list(peft_config['target_modules'])
return peft_config
def _create_parameter_buckets(named_params, bucket_size_mb=512):
"""Create parameter buckets for efficient processing"""
buckets = []
current_bucket = []
current_size = 0
bucket_size_bytes = bucket_size_mb * 1024 * 1024
for name, param in named_params:
param_size = param.numel() * param.element_size()
# If adding this param would exceed bucket size, process current bucket first
if current_size + param_size > bucket_size_bytes and current_bucket:
buckets.append(current_bucket)
current_bucket = []
current_size = 0
current_bucket.append((name, param))
current_size += param_size
# Process remaining parameters in the last bucket
if current_bucket:
buckets.append(current_bucket)
return buckets
def _process_bucket_with_flattened_tensor(trainer, bucket_params):
"""Process a bucket of parameters using FlattenedTensorBucket for efficiency"""
if not bucket_params:
return
# Create FlattenedTensorBucket for efficient processing
bucket = FlattenedTensorBucket(named_tensors=bucket_params)
metadatas = bucket.get_metadata()
flattened_tensor = bucket.get_flattened_tensor()
# Use the new flattened parameter update method
# If not available, fall back to individual parameter updates
try:
trainer.vllm_client.update_flattened_params(metadatas, flattened_tensor)
except AttributeError:
# Fallback to individual parameter updates
reconstructed = bucket.reconstruct_tensors()
for name, param in reconstructed.items():
trainer.vllm_client.update_named_param(name, param)
# Clean up
del bucket, metadatas, flattened_tensor
def get_even_process_data(trainer, global_data: List[T]) -> List[T]:
"""
Evenly splits `global_data` among all processes.
Each process receives a contiguous chunk of data. If `len(global_data)` is not
perfectly divisible by the number of processes, the first `remainder` processes
will receive one additional item.
Args:
global_data (List[T]): The full list of data to be distributed.
Returns:
List[T]: The subset of `global_data` assigned to this process.
"""
num_procs = trainer.accelerator.num_processes
proc_idx = trainer.accelerator.process_index
total = len(global_data)
base_size = total // num_procs
remainder = total % num_procs
# Calculate the number of samples that need to be padded
# This ensures all processes have the same number of samples for gather operations
trainer.rollout_pad_count = 0
if remainder > 0 and proc_idx >= remainder:
# Processes with extra samples need padding
trainer.rollout_pad_count = 1
if proc_idx < remainder:
start = proc_idx * (base_size + 1)
end = start + base_size + 1
else:
start = remainder * (base_size + 1) + (proc_idx - remainder) * base_size
end = start + base_size
return global_data[start:end]
def check_vllm_version_ge(min_version: str) -> bool:
"""check if the vllm version is greater than or equal to the minimum version"""
if not is_vllm_available():
return False
import vllm
vllm_version = vllm.__version__
# if dev version, regard it as latest version
if vllm_version is None or 'dev' in vllm_version:
return True
return version.parse(vllm_version) >= version.parse(min_version)
def vllm_supports_lora_load_inplace() -> bool:
"""True when vLLM LoRARequest supports load_inplace (replaces same lora_int_id without remove_lora).
Introduced in vLLM v0.15.0 (see vllm/lora/request.py). Older versions require remove_lora before add_lora
when reusing a stable adapter id.
"""
return check_vllm_version_ge('0.15.0')
# ============================================================================
# Padding-free utilities
# ============================================================================
def pad_logps_back_to_batch(logps_rmpad: Optional[torch.Tensor],
position_ids: Optional[torch.Tensor] = None,
logits_to_keep: int = None,
batch_size: int = None,
seq_lengths: Optional[torch.Tensor] = None,
dtype: Optional[torch.dtype] = None,
pad_value: float = -1e10) -> Tuple[torch.Tensor, torch.Tensor]:
"""
Restore padding-free logprobs back to [batch_size, seq_len] shape with LEFT PADDING.
- Input: logps in rmpad format [1, total_nnz] or None
- Output: logps in batch format [batch_size, max_seq_len] with data right-aligned
Args:
logps_rmpad: [1, total_nnz] per-token log probabilities in padding_free format or None
position_ids: [1, total_nnz] position ids to determine sequence boundaries (deprecated, use seq_lengths)
logits_to_keep: number of tokens to keep per sequence (= max_seq_len)
batch_size: number of sequences in the batch
seq_lengths: [batch_size] actual sequence lengths (preferred over position_ids)
dtype: optional dtype for output, defaults to logps_rmpad.dtype
pad_value: value to use for padding positions (default: -1e10 for logps, use 0.0 for masks)
Returns:
logps_padded: [batch_size, logits_to_keep] padded log probabilities (left-padded, data right-aligned) or None
valid_mask: [batch_size, logits_to_keep] mask indicating valid (non-padding) positions or None
"""
if logps_rmpad is None:
return None, None
if dtype is None:
dtype = logps_rmpad.dtype
device = logps_rmpad.device
# Determine sequence lengths
if seq_lengths is not None:
# Use provided seq_lengths directly - they should already be adjusted
# by the caller (e.g., in _generate_and_score_completions)
# DO NOT adjust again here to avoid double adjustment
pass
else:
# Fallback: infer from position_ids
cu_seqlens = get_cu_seqlens_from_position_ids(position_ids)
# Adjust cu_seqlens for logits_to_keep if needed
total_length = cu_seqlens[-1].item()
if total_length > logits_to_keep:
# Adjust the first sequence length
adjustment = total_length - logits_to_keep
cu_seqlens = cu_seqlens - adjustment
cu_seqlens[0] = 0 # First element should always be 0
# Compute actual sequence lengths
seq_lengths = cu_seqlens[1:] - cu_seqlens[:-1]
# Compute cumulative sequence lengths
cu_seqlens = torch.cumsum(torch.cat([torch.tensor([0], device=device), seq_lengths]), dim=0)
max_seq_len = logits_to_keep # All sequences will be padded to this length
# Initialize output tensors with padding value
logps_padded = torch.full((batch_size, max_seq_len), pad_value, dtype=dtype, device=device)
valid_mask = torch.zeros(batch_size, max_seq_len, dtype=torch.float32, device=device)
# Unflatten: assign each sequence's logps to the corresponding row
# Use LEFT PADDING (right-align the data) to match the standard padding convention
logps_flat = logps_rmpad.squeeze(0) # [total_nnz]
for i in range(batch_size):
start_idx = cu_seqlens[i].item()
end_idx = cu_seqlens[i + 1].item()
seq_len = int(seq_lengths[i].item())
actual_end_idx = min(end_idx, len(logps_flat))
actual_len = actual_end_idx - start_idx
if actual_len <= 0:
continue
# Left padding: place data at the RIGHT side of the row
# pad_len is the number of padding tokens at the beginning
pad_len = max_seq_len - seq_len
if actual_len < seq_len:
# Input data is shorter than expected seq_len
# This happens when logps_flat doesn't have enough data
# Place actual data at the rightmost positions
data_pad_len = max_seq_len - actual_len
logps_padded[i, data_pad_len:] = logps_flat[start_idx:actual_end_idx]
valid_mask[i, data_pad_len:] = 1.0
else:
# Normal case: seq_len tokens of data
logps_padded[i, pad_len:] = logps_flat[start_idx:end_idx]
valid_mask[i, pad_len:] = 1.0
return logps_padded, valid_mask
def build_completion_mask_and_seq_lengths(
labels: torch.Tensor,
batch_size: int,
*,
padding_free: bool = False,
encoded_batch: Optional[dict] = None,
device: Optional[torch.device] = None,
logits_to_keep: Optional[int] = None,
) -> Tuple[torch.Tensor, torch.Tensor, int]:
"""Build completion_mask and seq_lengths from labels, shared by HF / Megatron / Ray GRPO paths.
Two frame conventions, selected by ``logits_to_keep``:
- ``logits_to_keep is None`` -> full-sequence frame + roll (Megatron / Ray):
``completion_mask = roll(labels,-1) != -100``, shape ``[B, T_full]``.
- ``logits_to_keep is int`` -> completion-region frame, no roll (HF):
``completion_mask = labels[:, -ltk:] != -100``, shape ``[B, ltk]``; the per-sample
``seq_lengths`` (padding_free) carries the first-sentence prompt adjustment so it
matches HF's ``num_logits_to_keep`` logps frame.
Args:
labels: Label tensor from data collator.
batch_size: Number of samples in the batch.
padding_free: Whether padding-free (rmpad) mode is used.
encoded_batch: The full encoded batch dict (needed for cu_seq_lens / attention_mask / position_ids).
device: Target device for output tensors.
logits_to_keep: Region width for the HF frame; ``None`` selects the full-sequence frame.
Returns:
(completion_mask, seq_lengths, max_seq_len) where:
- completion_mask: ``[B, max_seq_len]`` bool tensor
- seq_lengths: ``[B]`` int tensor of per-sample lengths
- max_seq_len: int
"""
if device is None:
device = labels.device
if encoded_batch is None:
encoded_batch = {}
if logits_to_keep is None:
# Full-sequence frame + roll (Megatron / Ray)
rolled_labels = torch.roll(labels, shifts=-1, dims=-1)
if padding_free:
if 'cu_seq_lens_q' in encoded_batch:
cu = encoded_batch['cu_seq_lens_q']
else:
cu = get_packed_seq_params(encoded_batch['position_ids'])['cu_seq_lens_q']
seq_lengths = cu[1:] - cu[:-1]
max_seq_len = int(seq_lengths.max().item())
completion_mask_rmpad = (rolled_labels != -100).float()
completion_mask, _ = pad_logps_back_to_batch(
logps_rmpad=completion_mask_rmpad,
logits_to_keep=max_seq_len,
batch_size=batch_size,
seq_lengths=seq_lengths,
pad_value=0.0)
completion_mask = completion_mask.bool()
else:
attention_mask = encoded_batch.get('attention_mask')
if attention_mask is not None:
if attention_mask.dim() == 4:
attention_mask = attention_mask[:, 0, 0, :]
seq_lengths = attention_mask.sum(dim=-1).to(torch.int64)
else:
seq_lengths = torch.full((batch_size, ), labels.shape[-1], dtype=torch.int64, device=device)
max_seq_len = labels.shape[-1]
completion_mask = (rolled_labels != -100)
return completion_mask, seq_lengths, max_seq_len
# Completion-region frame, no roll (HF); aligns with num_logits_to_keep logps frame.
completion_mask_raw = labels[:, -logits_to_keep:] != -100
max_seq_len = logits_to_keep
if padding_free:
position_ids = encoded_batch.get('text_position_ids')
if position_ids is None:
position_ids = encoded_batch.get('position_ids')
position_ids = position_ids.squeeze()
lengths = torch.diff(
torch.cat([(position_ids == 0).nonzero(as_tuple=True)[0],
torch.tensor([len(position_ids)]).to(position_ids.device)]))
total_lengths = lengths.sum()
# The first sentence has its prompt portion removed due to logits_to_keep
lengths[0] = lengths[0] - (total_lengths - logits_to_keep)
seq_lengths = lengths
completion_mask, _ = pad_logps_back_to_batch(
logps_rmpad=completion_mask_raw.float(),
logits_to_keep=logits_to_keep,
batch_size=batch_size,
seq_lengths=lengths,
pad_value=0.0)
completion_mask = completion_mask.bool()
else:
completion_mask = completion_mask_raw
# Non-padding-free HF frame: every row spans the full region. Return real
# per-sample lengths (= region width) instead of an empty tensor, so callers
# can treat seq_lengths uniformly regardless of padding_free.
seq_lengths = torch.full((batch_size, ), logits_to_keep, dtype=torch.int64, device=device)
return completion_mask, seq_lengths, max_seq_len
def build_rollout_logps(
rollout_logprobs_list: List[Optional[List[List[float]]]],
completion_mask: torch.Tensor,
device: torch.device,
) -> Optional[torch.Tensor]:
"""Convert per-sample ``rollout_logprobs`` into a [B, T] tensor aligned with completion_mask.
Data-structure agnostic: callers pass a list of per-sample nested logprobs
(``List[List[float]]`` per sample, or ``None``).
Returns None if logprobs are missing or counts don't match.
"""
lp_list = list(rollout_logprobs_list)
if not all(lp is not None and lp for lp in lp_list):
return None
batch_size, seq_len = completion_mask.shape
rollout_per_token_logps = torch.zeros(batch_size, seq_len, dtype=torch.float32, device=device)
for i, nested_lp in enumerate(lp_list):
flat_lps = [lp for turn_lps in nested_lp for lp in turn_lps]
if not flat_lps:
continue
if any(lp is None for lp in flat_lps):
return None
completion_count = int(completion_mask[i].sum().item())
if len(flat_lps) == completion_count + 1:
flat_lps = flat_lps[:completion_count]
if len(flat_lps) != completion_count:
return None
completion_indices = completion_mask[i].nonzero(as_tuple=True)[0]
rollout_per_token_logps[i, completion_indices] = torch.tensor(flat_lps, dtype=torch.float32, device=device)
return rollout_per_token_logps
def _normalize_routed_experts_tensor(value: Any) -> torch.Tensor:
routed = value.detach().cpu() if isinstance(value, torch.Tensor) else torch.as_tensor(value)
if routed.dim() >= 4 and routed.shape[0] == 1:
routed = routed.squeeze(0)
if routed.dim() < 2:
raise ValueError(f'Invalid routed_experts shape: {tuple(routed.shape)}')
return routed.to(dtype=torch.int64)
def _pad_or_trim_routed_experts(routed: torch.Tensor, target_len: int, *, padding_right: bool) -> torch.Tensor:
current_len = int(routed.shape[0])
if current_len == target_len:
return routed
if current_len > target_len:
return routed[:target_len] if padding_right else routed[-target_len:]
pad_len = target_len - current_len
pad = [0] * (2 * routed.dim())
if padding_right:
pad[2 * (routed.dim() - 1) + 1] = pad_len
else:
pad[2 * (routed.dim() - 1)] = pad_len
return torch.nn.functional.pad(routed, tuple(pad), 'constant', 0)
def build_routed_experts_batch(
samples: List[OnPolicySample],
*,
seq_lengths: torch.Tensor,
max_seq_len: int,
template: Template,
device: torch.device,
router_replay_mode: str = 'disabled',
) -> Optional[torch.Tensor]:
"""Build the batched ``routed_experts`` model input from per-sample R3 routing.
Shared by all backends. Each ``sample`` is an :class:`OnPolicySample` carrying
``routed_experts`` (per-sample, seq-first) and ``encoded['length']``. Returns
``None`` when no sample provides routing (and mode is not ``R3``).
"""
if not samples or all(getattr(s, 'routed_experts', None) is None for s in samples):
return None
padding_right = template.padding_side == 'right'
n_samples = len(samples)
current_seq_lengths = seq_lengths
if seq_lengths.size(0) > n_samples:
current_seq_lengths = seq_lengths[:n_samples].clone()
current_seq_lengths[n_samples - 1] = seq_lengths[n_samples - 1:].sum()
routed_tensors: List[torch.Tensor] = []
for sample, cur_seq_len in zip(samples, current_seq_lengths):
routed_value = getattr(sample, 'routed_experts', None)
if routed_value is None:
if router_replay_mode == 'R3':
raise AssertionError('When router_replay_mode = R3, routed_experts must be in rollout data')
return None
routed = _normalize_routed_experts_tensor(routed_value)
expected_len = (sample.encoded or {}).get('length')
experts_seq_len = int(routed.shape[0])
if router_replay_mode == 'R3' and expected_len is not None:
if experts_seq_len not in (expected_len, expected_len - 1):
raise AssertionError(f'The seq_len of routed_experts({experts_seq_len}) does not match encoded length '
f'({expected_len}); expected same length or one less.')
target_len = int(cur_seq_len.item()) if template.padding_free else max_seq_len
routed = _pad_or_trim_routed_experts(routed, target_len, padding_right=padding_right)
routed_tensors.append(routed)
if template.padding_free:
return torch.cat(routed_tensors, dim=0).unsqueeze(0).to(device=device)
return torch.stack(routed_tensors).to(device=device)
def collate_to_grpo_micro_batch(
samples: List[OnPolicySample],
template: Template,
*,
device: torch.device,
padding_to: Optional[int] = None,
router_replay_mode: str = 'disabled',
use_logits_to_keep: bool = False,
) -> Tuple[Dict[str, Any], GRPOBatch]:
"""Collate ``List[OnPolicySample]`` into ``(model_inputs, grpo_batch)``.
The single shared collate used by HF / Megatron / Megatron-Ray. Splits the
per-sample world into two batch-level halves:
- ``model_inputs`` (dict): ``data_collator([s.encoded ...])`` plus batch-computed
model extras (``routed_experts``). A clean whitelist — ``model(**model_inputs)``
needs no key filtering.
- ``grpo_batch`` (:class:`GRPOBatch`): ``completion_mask`` / ``truncated_mask`` /
``seq_lengths`` / ``rollout_per_token_logps`` derived purely from the collated batch.
``use_logits_to_keep`` selects the completion_mask frame (see
:func:`build_completion_mask_and_seq_lengths`):
- ``False`` -> full-sequence frame + roll (Megatron / Ray).
- ``True`` -> completion-region frame (HF); ``logits_to_keep`` is computed from
the collated labels and stored on ``grpo_batch`` for the HF logps path.
Backend-specific signals (``old_per_token_logps`` / ``ref_per_token_logps`` /
``advantages`` / ``num_items_in_batch``) are filled by the caller afterwards —
non-Ray via batch forward, Ray by stacking per-sample remote results. No
distributed communication happens here.
"""
encoded_list = [s.encoded for s in samples]
model_inputs = to_device(template.data_collator(encoded_list, padding_to=padding_to), device)
labels = model_inputs['labels']
batch_size = len(samples)
logits_to_keep = None
if use_logits_to_keep:
logits_to_keep = int((labels.shape[-1] - (torch.ne(labels, -100).int().argmax(-1))).max().item())
completion_mask, seq_lengths, max_seq_len = build_completion_mask_and_seq_lengths(
labels,
batch_size,
padding_free=template.padding_free,
encoded_batch=model_inputs,
device=device,
logits_to_keep=logits_to_keep,
)
truncated_mask = torch.tensor([bool(s.is_truncated) for s in samples], dtype=torch.bool, device=device)
rollout_per_token_logps = build_rollout_logps([s.rollout_logprobs for s in samples], completion_mask, device)
routed_experts = build_routed_experts_batch(
samples,
seq_lengths=seq_lengths,
max_seq_len=max_seq_len,
template=template,
device=device,
router_replay_mode=router_replay_mode,
)
if routed_experts is not None:
model_inputs['routed_experts'] = routed_experts
grpo_batch = GRPOBatch(
completion_mask=completion_mask,
truncated_mask=truncated_mask,
seq_lengths=seq_lengths,
rollout_per_token_logps=rollout_per_token_logps,
logits_to_keep=logits_to_keep,
)
return model_inputs, grpo_batch
def resolve_reward_funcs(
reward_funcs_cfg: list,
args: Any = None,
) -> Tuple[List[Any], List[str]]:
"""Resolve reward function configs into callables and their names.
Shared between ``MegatronGRPOTrainer._prepare_rewards`` and
``GRPOTrainer._prepare_rewards``.
Returns:
(reward_funcs, reward_func_names)
"""
import asyncio
import inspect
from swift.rewards import orms
reward_funcs = list(reward_funcs_cfg)
for i, reward_func in enumerate(reward_funcs):
if isinstance(reward_func, str) and reward_func in orms:
reward_funcs[i] = orms[reward_func](args=args) if args is not None else orms[reward_func]()
elif not callable(reward_func) and not isinstance(reward_func, str):
raise ValueError(f'reward_function {reward_func} is not implemented in swift.rewards')
names = []
for func in reward_funcs:
if inspect.isfunction(func):
names.append(func.__name__)
else:
names.append(func.__class__.__name__)
return reward_funcs, names
def make_reward_weights(
reward_weights_cfg: Optional[List[float]],
num_funcs: int,
device: torch.device,
) -> torch.Tensor:
"""Build reward weight tensor, validating length against the reward
function count."""
if reward_weights_cfg is not None:
if len(reward_weights_cfg) != num_funcs:
raise ValueError(f'Number of reward weights ({len(reward_weights_cfg)}) must '
f'match number of reward functions ({num_funcs})')
return torch.tensor(reward_weights_cfg, dtype=torch.float32, device=device)
return torch.ones(num_funcs, dtype=torch.float32, device=device)