426 lines
21 KiB
Python
426 lines
21 KiB
Python
# Copyright (c) ModelScope Contributors. All rights reserved.
|
|
import os
|
|
from dataclasses import dataclass
|
|
from transformers.utils.versions import require_version
|
|
from typing import Literal, Optional
|
|
|
|
from swift.trainers import Seq2SeqTrainingArguments, TrainerFactory
|
|
from swift.trainers.utils import prepare_deepspeed_elastic_config
|
|
from swift.utils import (add_version_to_work_dir, get_device_count, get_logger, get_pai_tensorboard_dir, is_mp,
|
|
is_pai_training_job, is_swanlab_available, json_parse_to_dict, to_abspath)
|
|
from .base_args import BaseArguments
|
|
from .tuner_args import TunerArguments
|
|
|
|
logger = get_logger()
|
|
|
|
|
|
@dataclass
|
|
class SwanlabArguments:
|
|
"""Arguments for configuring Swanlab for experiment result logging.
|
|
|
|
This dataclass stores all the configuration parameters required for initializing and using Swanlab to track
|
|
experiments.
|
|
|
|
Args:
|
|
swanlab_token (Optional[str]): The API key for SwanLab. You can also specify it using the `SWANLAB_API_KEY`
|
|
environment variable.
|
|
swanlab_project (str): The SwanLab project, which can be created in advance on the page
|
|
[https://swanlab.cn/space/~](https://swanlab.cn/space/~) or created automatically.
|
|
The default is "ms-swift".
|
|
swanlab_workspace (Optional[str]): The SwanLab workspace. Defaults to `None`, in which case the username
|
|
associated with the API key will be used.
|
|
swanlab_exp_name (Optional[str]): The name of the experiment. If `None`, it will default to the value of the
|
|
`output_dir` argument.
|
|
swanlab_notification_method (Optional[str]): The notification method for SwanLab when training completes
|
|
or errors occur. For details, refer to [here](https://docs.swanlab.cn/plugin/notification-dingtalk.html).
|
|
Supports 'dingtalk', 'lark', 'email', 'discord', 'wxwork', 'slack'.
|
|
swanlab_webhook_url (Optional[str]): Defaults to None. The webhook URL corresponding to
|
|
SwanLab's `swanlab_notification_method`.
|
|
swanlab_secret (Optional[str]): Defaults to None. The secret corresponding to
|
|
SwanLab's `swanlab_notification_method`.
|
|
swanlab_sender_email (Optional[str]): The email address of the sender. Required when
|
|
`swanlab_notification_method` is 'email'.
|
|
swanlab_receiver_email (Optional[str]): The email address of the receiver. Required when
|
|
`swanlab_notification_method` is 'email'.
|
|
swanlab_smtp_server (Optional[str]): The SMTP server address for email notification (e.g., 'smtp.qq.com').
|
|
swanlab_smtp_port (Optional[int]): The SMTP server port for email notification (e.g., 465).
|
|
swanlab_email_language (Optional[str]): email messages language. Supports 'zh', 'en'. The default is "zh".
|
|
swanlab_mode (Literal['cloud', 'local']): The operation mode, either 'cloud' for cloud-based logging or 'local'
|
|
for local-only logging.
|
|
"""
|
|
swanlab_token: Optional[str] = None
|
|
swanlab_project: str = 'ms-swift'
|
|
swanlab_workspace: Optional[str] = None
|
|
swanlab_exp_name: Optional[str] = None
|
|
swanlab_notification_method: Optional[str] = None
|
|
swanlab_webhook_url: Optional[str] = None
|
|
swanlab_secret: Optional[str] = None
|
|
swanlab_sender_email: Optional[str] = None
|
|
swanlab_receiver_email: Optional[str] = None
|
|
swanlab_smtp_server: Optional[str] = None
|
|
swanlab_smtp_port: Optional[int] = None
|
|
swanlab_email_language: Optional[str] = 'zh'
|
|
swanlab_mode: Literal['cloud', 'local'] = 'cloud'
|
|
|
|
def _init_swanlab(self):
|
|
if not is_swanlab_available():
|
|
raise ValueError('You are using swanlab as `report_to`, please install swanlab by '
|
|
'`pip install swanlab`')
|
|
if not self.swanlab_exp_name:
|
|
self.swanlab_exp_name = self.output_dir
|
|
import swanlab
|
|
from swanlab.integration.transformers import SwanLabCallback
|
|
from transformers.integrations import INTEGRATION_TO_CALLBACK
|
|
if self.swanlab_token:
|
|
swanlab.login(self.swanlab_token)
|
|
|
|
if self.swanlab_notification_method is not None:
|
|
from swanlab.plugin.notification import (DingTalkCallback, DiscordCallback, EmailCallback, LarkCallback,
|
|
SlackCallback, WXWorkCallback)
|
|
notification_mapping = {
|
|
'lark': LarkCallback,
|
|
'dingtalk': DingTalkCallback,
|
|
'email': EmailCallback,
|
|
'discord': DiscordCallback,
|
|
'wxwork': WXWorkCallback,
|
|
'slack': SlackCallback,
|
|
}
|
|
callback_cls = notification_mapping.get(self.swanlab_notification_method)
|
|
if callback_cls is None:
|
|
raise ValueError(
|
|
f'Unsupported swanlab_notification_method: "{self.swanlab_notification_method}". Supported methods'
|
|
f' are: {list(notification_mapping.keys())}')
|
|
|
|
if self.swanlab_notification_method == 'email':
|
|
if not (self.swanlab_sender_email and self.swanlab_receiver_email and self.swanlab_smtp_server
|
|
and self.swanlab_smtp_port):
|
|
raise ValueError("When 'swanlab_notification_method' is 'email', both 'swanlab_sender_email' "
|
|
"and 'swanlab_receiver_email' and 'swanlab_smtp_server' and 'swanlab_smtp_port' "
|
|
'must be provided.')
|
|
callback = EmailCallback(
|
|
sender_email=self.swanlab_sender_email,
|
|
receiver_email=self.swanlab_receiver_email,
|
|
password=self.swanlab_secret,
|
|
smtp_server=self.swanlab_smtp_server,
|
|
port=self.swanlab_smtp_port,
|
|
language=self.swanlab_email_language)
|
|
else:
|
|
callback = callback_cls(
|
|
webhook_url=self.swanlab_webhook_url,
|
|
secret=self.swanlab_secret,
|
|
)
|
|
swanlab.register_callbacks([callback])
|
|
|
|
INTEGRATION_TO_CALLBACK['swanlab'] = SwanLabCallback(
|
|
project=self.swanlab_project,
|
|
workspace=self.swanlab_workspace,
|
|
experiment_name=self.swanlab_exp_name,
|
|
config={'UPPERFRAME': '🐦⬛ms-swift'},
|
|
mode=self.swanlab_mode,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class SftArguments(SwanlabArguments, TunerArguments, BaseArguments, Seq2SeqTrainingArguments):
|
|
"""Arguments pertaining to the training process.
|
|
|
|
SftArguments is a dataclass that inherits from multiple argument classes: SwanlabArguments, TunerArguments,
|
|
BaseArguments, Seq2SeqTrainingArguments.
|
|
|
|
Args:
|
|
add_version (bool): Whether to add a versioned subdirectory like '<version>-<timestamp>' to the `output_dir` to
|
|
prevent overwriting existing checkpoints. Defaults to True.
|
|
create_checkpoint_symlink (bool): Whether to create additional symbolic links for checkpoints, which can be
|
|
useful for automated training scripts. The symlinks for the best and last models will be created at
|
|
`f'{output_dir}/best'` and `f'{output_dir}/last'`, respectively. Defaults to False.
|
|
output_dir (Optional[str]): The directory to save model outputs. Defaults to 'output/<model_name>'.
|
|
learning_rate (Optional[float]): The learning rate. Defaults to 1e-5 for full-parameter training and 1e-4 for
|
|
tuners like LoRA.
|
|
Note: To set a minimum learning rate (min_lr), you can pass the arguments
|
|
--lr_scheduler_type cosine_with_min_lr --lr_scheduler_kwargs '{"min_lr": 1e-6}'.
|
|
eval_strategy (Optional[str]): The evaluation strategy. By default, it aligns with `save_strategy`. It will
|
|
default to 'no' if no validation dataset is provided (i.e., `val_dataset` and `eval_dataset` are not used,
|
|
and `split_dataset_ratio` is 0).
|
|
fp16 (Optional[bool]): Defaults to None.
|
|
bf16 (Optional[bool]): Defaults to None.
|
|
max_new_tokens (int): Overrides generation parameters. The maximum number of new tokens to generate when
|
|
`predict_with_generate` is True. Defaults to 64.
|
|
temperature (float): Overrides generation parameters. The temperature for sampling when `predict_with_generate`
|
|
is True. Defaults to 0.0.
|
|
load_args (bool): Whether to load `args.json` from a saved directory when `--resume_from_checkpoint`,
|
|
`--model`, or `--adapters` is specified. For details on which keys are loaded, refer to `base_args.py`.
|
|
Defaults to `True` for inference and exporting, and `False` for training. This argument typically does not
|
|
need to be modified.
|
|
zero_hpz_partition_size (Optional[int]): A feature of ZeRO++. Enables model sharding within a node and data
|
|
sharding between nodes. If you encounter `grad_norm` NaN issues, consider trying `--torch_dtype float16`.
|
|
Defaults to None.
|
|
deepspeed_autotp_size (Optional[int]): The tensor parallelism size for DeepSpeed AutoTP. To use this, the
|
|
`--deepspeed` argument must be set to 'zero0', 'zero1', or 'zero2'. Note: This feature only supports
|
|
full-parameter fine-tuning. Defaults to None.
|
|
"""
|
|
add_version: bool = True
|
|
create_checkpoint_symlink: bool = False
|
|
|
|
# override
|
|
output_dir: Optional[str] = None
|
|
learning_rate: Optional[float] = None
|
|
eval_strategy: Optional[str] = None # steps, epoch
|
|
fp16: Optional[bool] = None
|
|
bf16: Optional[bool] = None
|
|
|
|
# extra
|
|
max_new_tokens: int = 64
|
|
temperature: float = 0.
|
|
load_args: bool = False
|
|
|
|
# zero++
|
|
zero_hpz_partition_size: Optional[int] = None
|
|
|
|
# auto_tp
|
|
deepspeed_autotp_size: Optional[int] = None
|
|
|
|
# fsdp
|
|
fsdp: Optional[str] = None
|
|
|
|
def _check_padding_free(self):
|
|
if self.padding_free or self.packing:
|
|
if self.packing:
|
|
feature = 'packing'
|
|
self.padding_free = True
|
|
else:
|
|
feature = 'padding_free'
|
|
supported_impls = ['flash_attn', 'flash_attention_2', 'flash_attention_3', 'flash_attention_4']
|
|
if self.attn_impl not in supported_impls:
|
|
supported_impls_str = ', '.join([f'"{impl}"' for impl in supported_impls])
|
|
raise ValueError(f'The "{feature}" feature requires a flash attention implementation. '
|
|
f'Please use one of: {supported_impls_str}.')
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.resume_from_checkpoint:
|
|
self.resume_from_checkpoint = to_abspath(self.resume_from_checkpoint, True)
|
|
# The non-resume_only_model will have its weights loaded in the trainer.
|
|
if self.resume_only_model:
|
|
if self.tuner_type == 'full':
|
|
self.model = self.resume_from_checkpoint
|
|
else:
|
|
self.adapters = [self.resume_from_checkpoint]
|
|
BaseArguments.__post_init__(self)
|
|
self._init_override()
|
|
TunerArguments.__post_init__(self)
|
|
self._check_padding_free()
|
|
if self.vit_gradient_checkpointing is None:
|
|
self.vit_gradient_checkpointing = not self.freeze_vit
|
|
if self.optimizer is None:
|
|
if self.lorap_lr_ratio:
|
|
self.optimizer = 'lorap'
|
|
elif self.use_galore:
|
|
self.optimizer = 'galore'
|
|
|
|
if len(self.dataset) == 0 and len(self.cached_dataset) == 0:
|
|
raise ValueError(f'self.dataset: {self.dataset}, self.cached_dataset: {self.cached_dataset}. '
|
|
'Please input the training dataset.')
|
|
|
|
self._handle_pai_compat()
|
|
|
|
self._init_deepspeed()
|
|
self._init_fsdp()
|
|
self._init_device()
|
|
|
|
if getattr(self, 'accelerator_config', None) is None:
|
|
self.accelerator_config = {'dispatch_batches': False}
|
|
if not (self.eval_dataset or self._val_dataset_exists):
|
|
self.eval_strategy = 'no'
|
|
self.training_args = TrainerFactory.get_training_args(self)
|
|
self.training_args.remove_unused_columns = False
|
|
self._add_version()
|
|
|
|
if 'swanlab' in self.report_to:
|
|
self._init_swanlab()
|
|
|
|
def _init_override(self):
|
|
self._init_output_dir()
|
|
self._init_metric()
|
|
|
|
if self.learning_rate is None:
|
|
if self.tuner_type == 'full':
|
|
self.learning_rate = 1e-5
|
|
else:
|
|
self.learning_rate = 1e-4
|
|
self._init_eval_strategy()
|
|
|
|
def _init_deepspeed(self):
|
|
if self.deepspeed:
|
|
require_version('deepspeed')
|
|
if is_mp() and not self.use_ray:
|
|
raise ValueError('DeepSpeed is not compatible with `device_map`. '
|
|
f'n_gpu: {get_device_count()}, '
|
|
f'local_world_size: {self.local_world_size}.')
|
|
|
|
ds_config_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'config'))
|
|
deepspeed_mapping = {
|
|
name: f'{name}.json'
|
|
for name in ['zero0', 'zero1', 'zero2', 'zero3', 'zero2_offload', 'zero3_offload']
|
|
}
|
|
for ds_name, ds_config in deepspeed_mapping.items():
|
|
if self.deepspeed == ds_name:
|
|
self.deepspeed = os.path.join(ds_config_folder, ds_config)
|
|
break
|
|
|
|
self.deepspeed = json_parse_to_dict(self.deepspeed)
|
|
if self.zero_hpz_partition_size is not None:
|
|
assert 'zero_optimization' in self.deepspeed
|
|
self.deepspeed['zero_optimization']['zero_hpz_partition_size'] = self.zero_hpz_partition_size
|
|
logger.warn('If `zero_hpz_partition_size`(ZeRO++) causes grad_norm NaN, please'
|
|
' try `--torch_dtype float16`')
|
|
if self.deepspeed_autotp_size is not None:
|
|
assert self.deepspeed is not None, (
|
|
'To use `deepspeed_autotp_size`, you need to additionally set the `--deepspeed` argument.')
|
|
self.deepspeed.setdefault('tensor_parallel', {})['autotp_size'] = self.deepspeed_autotp_size
|
|
self.deepspeed.setdefault('zero_optimization', {})['gather_16bit_weights_on_model_save'] = True
|
|
if 'deepspeed_elastic' in set(getattr(self, 'callbacks', []) or []):
|
|
prepare_deepspeed_elastic_config(self)
|
|
logger.info(f'Using deepspeed: {self.deepspeed}')
|
|
|
|
def _init_fsdp(self):
|
|
if not self.fsdp:
|
|
self.fsdp = []
|
|
return
|
|
|
|
if is_mp() and not self.use_ray:
|
|
raise ValueError('FSDP2 is not compatible with `device_map`. '
|
|
f'n_gpu: {get_device_count()}, '
|
|
f'local_world_size: {self.local_world_size}.')
|
|
if self.deepspeed:
|
|
raise ValueError('FSDP2 is not compatible with DeepSpeed.')
|
|
|
|
fsdp_config_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'config'))
|
|
|
|
# FSDP2 preset configurations
|
|
fsdp_mapping = {
|
|
'fsdp2': 'fsdp2.json',
|
|
}
|
|
|
|
fsdp_config_path = self.fsdp
|
|
for fsdp_name, fsdp_config in fsdp_mapping.items():
|
|
if self.fsdp == fsdp_name:
|
|
fsdp_config_path = os.path.join(fsdp_config_folder, fsdp_config)
|
|
break
|
|
|
|
fsdp_config_dict = json_parse_to_dict(fsdp_config_path)
|
|
|
|
# Extract fsdp string options (e.g., "full_shard auto_wrap offload")
|
|
fsdp_options = fsdp_config_dict.get('fsdp', 'full_shard auto_wrap')
|
|
self.fsdp = fsdp_options
|
|
|
|
# Extract fsdp_config dict
|
|
self.fsdp_config = fsdp_config_dict.get('fsdp_config', {})
|
|
|
|
# Set FSDP_VERSION environment variable for accelerate to recognize FSDP2
|
|
fsdp_version = self.fsdp_config.get('fsdp_version', 2)
|
|
os.environ['FSDP_VERSION'] = str(fsdp_version)
|
|
|
|
# Set environment variable to optimize NCCL memory usage
|
|
if 'TORCH_NCCL_AVOID_RECORD_STREAMS' not in os.environ:
|
|
os.environ['TORCH_NCCL_AVOID_RECORD_STREAMS'] = '1'
|
|
|
|
# Check FSDP2 compatibility with other training arguments
|
|
self._check_fsdp2_compatibility()
|
|
|
|
logger.info(f'Using FSDP2: fsdp={self.fsdp}, fsdp_config={self.fsdp_config}')
|
|
|
|
def _check_fsdp2_compatibility(self):
|
|
"""Check for incompatible argument combinations with FSDP2.
|
|
|
|
FSDP2 has several known limitations:
|
|
1. save_only_model=True + SHARDED_STATE_DICT: Can't save only model weights with sharded state dict
|
|
2. gradient_checkpointing=True: Should use activation_checkpointing in fsdp_config instead
|
|
"""
|
|
state_dict_type = self.fsdp_config.get('state_dict_type', 'SHARDED_STATE_DICT')
|
|
|
|
# Check 1: save_only_model + SHARDED_STATE_DICT
|
|
if getattr(self, 'save_only_model', False) and 'SHARDED' in state_dict_type.upper():
|
|
raise ValueError(
|
|
'FSDP2 with SHARDED_STATE_DICT is not compatible with save_only_model=True. '
|
|
'Either set save_only_model=False, or change state_dict_type to FULL_STATE_DICT in fsdp_config. '
|
|
'Note: FULL_STATE_DICT requires more memory and is slower.')
|
|
|
|
# Check 2: gradient_checkpointing should be disabled, use activation_checkpointing instead
|
|
if getattr(self, 'gradient_checkpointing', False):
|
|
activation_checkpointing = self.fsdp_config.get('activation_checkpointing', False)
|
|
if activation_checkpointing:
|
|
logger.warning('Both gradient_checkpointing and fsdp_config.activation_checkpointing are enabled. '
|
|
'For FSDP2, it is recommended to use only activation_checkpointing in fsdp_config. '
|
|
'Disabling gradient_checkpointing automatically.')
|
|
self.gradient_checkpointing = False
|
|
else:
|
|
logger.warning(
|
|
'gradient_checkpointing is enabled with FSDP2. '
|
|
'For better performance, consider using activation_checkpointing in fsdp_config instead. '
|
|
'Add "activation_checkpointing": true to your fsdp_config.')
|
|
|
|
def _handle_pai_compat(self) -> None:
|
|
if not is_pai_training_job():
|
|
return
|
|
|
|
logger.info('Handle pai compat...')
|
|
pai_tensorboard_dir = get_pai_tensorboard_dir()
|
|
if self.logging_dir is None and pai_tensorboard_dir is not None:
|
|
self.logging_dir = pai_tensorboard_dir
|
|
logger.info(f'Setting args.logging_dir: {self.logging_dir}')
|
|
self.add_version = False
|
|
logger.info(f'Setting args.add_version: {self.add_version}')
|
|
|
|
def _add_version(self):
|
|
"""Prepare the output_dir"""
|
|
if self.add_version:
|
|
self.output_dir = add_version_to_work_dir(self.output_dir)
|
|
logger.info(f'output_dir: {self.output_dir}')
|
|
|
|
if self.logging_dir is None:
|
|
self.logging_dir = f'{self.output_dir}/runs'
|
|
|
|
self.logging_dir = to_abspath(self.logging_dir)
|
|
os.makedirs(self.output_dir, exist_ok=True)
|
|
|
|
if self.run_name is None:
|
|
self.run_name = self.output_dir
|
|
|
|
self.training_args.output_dir = self.output_dir
|
|
self.training_args.run_name = self.run_name
|
|
self.training_args.logging_dir = self.logging_dir
|
|
|
|
def _init_output_dir(self):
|
|
if self.output_dir is None:
|
|
self.output_dir = f'output/{self.model_suffix}'
|
|
self.output_dir = to_abspath(self.output_dir)
|
|
|
|
def _init_eval_strategy(self):
|
|
if self.eval_strategy is None:
|
|
self.eval_strategy = self.save_strategy
|
|
if self.eval_strategy == 'no':
|
|
self.eval_steps = None
|
|
if self.split_dataset_ratio > 0:
|
|
self.split_dataset_ratio = 0.
|
|
logger.info(f'Setting args.split_dataset_ratio: {self.split_dataset_ratio}')
|
|
elif self.eval_strategy == 'steps' and self.eval_steps is None:
|
|
self.eval_steps = self.save_steps
|
|
self.evaluation_strategy = self.eval_strategy
|
|
|
|
def _init_metric(self):
|
|
if self.eval_metric is None:
|
|
if self.task_type == 'causal_lm' and self.predict_with_generate:
|
|
self.eval_metric = 'nlg'
|
|
elif self.task_type == 'embedding':
|
|
self.eval_metric = 'infonce' if self.loss_type == 'infonce' else 'paired'
|
|
elif self.task_type in {'reranker', 'generative_reranker'}:
|
|
self.eval_metric = 'reranker'
|
|
if self.eval_metric == 'nlg':
|
|
require_version('jieba', 'Setting `--eval_metric nlg` requires installing the jieba dependency.')
|
|
self._init_metric_for_best_model()
|
|
|
|
def _init_metric_for_best_model(self):
|
|
if self.metric_for_best_model is None:
|
|
self.metric_for_best_model = 'rouge-l' if self.predict_with_generate else 'loss'
|
|
if self.greater_is_better is None and self.metric_for_best_model is not None:
|
|
self.greater_is_better = 'loss' not in self.metric_for_best_model
|