2201 lines
86 KiB
Python
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)
|