UMA / train.py
OneScience's picture
Upload folder using huggingface_hub
bf314e8 verified
Raw
History Blame Contribute Delete
23.2 kB
from __future__ import annotations
import argparse
import logging
import os
import random
import tempfile
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING, Optional
# import clusterscope
try:
import clusterscope
except Exception:
clusterscope = None
import hydra
import numpy as np
import torch
from omegaconf import OmegaConf
from omegaconf.errors import InterpolationKeyError
from onescience.utils.uma.common import gp_utils
if TYPE_CHECKING:
from omegaconf import DictConfig
from onescience.utils.uma.components.reducer import Reducer
from onescience.utils.uma.components.runner import Runner
from submitit import AutoExecutor
from submitit.core.utils import JobPaths, cloudpickle_dump
from submitit.helpers import Checkpointable, DelayedSubmission
from submitit.slurm.slurm import SlurmJobEnvironment
from onescience.distributed.manager import DistributedManager
from onescience.utils.uma.common import distutils
from onescience.monitoring.uma.logger import WandBSingletonLogger
from onescience.monitoring.uma.runtime_logging import setup_logging
from onescience.utils.uma.common.utils import (
get_commit_hash,
get_timestamp_uid,
setup_env_vars,
)
# this effects the cli only since the actual job will be run in subprocesses or remoe
logging.basicConfig(level=logging.INFO)
ALLOWED_TOP_LEVEL_KEYS = {"job", "runner", "reducer"}
LOG_DIR_NAME = "logs"
CHECKPOINT_DIR_NAME = "checkpoints"
RESULTS_DIR = "results"
CONFIG_FILE_NAME = "canonical_config.yaml"
PREEMPTION_STATE_DIR_NAME = "preemption_state"
class SchedulerType(str, Enum):
LOCAL = "local"
SLURM = "slurm"
class DeviceType(str, Enum):
CPU = "cpu"
CUDA = "cuda"
class RunType(str, Enum):
RUN = "run"
REDUCE = "reduce"
class DistributedInitMethod(str, Enum):
TCP = "tcp"
FILE = "file"
@dataclass
class SlurmConfig:
mem_gb: int = 80
timeout_hr: int = 168
cpus_per_task: int = 8
partition: Optional[str] = (
None # omegaconf in python 3.9 does not backport annotations
)
qos: Optional[str] = None # omegaconf in python 3.9 does not backport annotations
account: Optional[str] = (
None # omegaconf in python 3.9 does not backport annotations
)
additional_parameters: Optional[dict] = None # 字典格式,用于存储环境变量和配置
@dataclass
class SchedulerConfig:
mode: SchedulerType = SchedulerType.LOCAL
distributed_init_method: DistributedInitMethod = DistributedInitMethod.TCP
ranks_per_node: int = 1
num_nodes: int = 1
num_array_jobs: int = 1
# 新增:仅 LOCAL+elastic 多节点时使用(也可不填,见下文)
rdzv_backend: str = "c10d"
rdzv_endpoint: Optional[str] = None
run_id: str = field(default_factory=lambda: f"run_{uuid.uuid4().hex[:8]}")
slurm: SlurmConfig = field(default_factory=lambda: SlurmConfig)
@dataclass
class SlurmEnv:
# reflects the job_id given by submitit (slurm id with array job id and array task id if they exist)
job_id: Optional[str] = (
None # omegaconf in python 3.9 does not backport annotations
)
# reflects SLURM_JOB_ID only
raw_job_id: Optional[str] = (
None # omegaconf in python 3.9 does not backport annotations
)
# SLURM_ARRAY_JOB_ID
array_job_id: Optional[str] = (
None # omegaconf in python 3.9 does not backport annotations
)
# SLURM_ARRAY_TASK_ID
array_task_id: Optional[str] = (
None # omegaconf in python 3.9 does not backport annotations
)
# reflects SLURM_RESTART_COUNT env variable
restart_count: Optional[str] = (
None # omegaconf in python 3.9 does not backport annotations
)
@dataclass
class Metadata:
# read-only metadata about the job, not user inputs
commit: str
log_dir: str
checkpoint_dir: str
results_dir: str
config_path: str
preemption_checkpoint_dir: str
cluster_name: str
array_job_num: int = 0
slurm_env: SlurmEnv = field(default_factory=lambda: SlurmEnv())
@dataclass
class JobConfig:
run_name: str = field(
default_factory=lambda: get_timestamp_uid() + uuid.uuid4().hex.upper()[0:4]
)
timestamp_id: str = field(default_factory=lambda: get_timestamp_uid())
run_dir: str = field(default_factory=lambda: tempfile.TemporaryDirectory().name)
device_type: DeviceType = DeviceType.CUDA
debug: bool = False
scheduler: SchedulerConfig = field(default_factory=lambda: SchedulerConfig)
logger: Optional[dict] = (
None # omegaconf in python 3.9 does not backport annotations
)
seed: int = 0
deterministic: bool = False
runner_state_path: Optional[str] = (
None # omegaconf in python 3.9 does not backport annotations
)
# read-only metadata about the job, not user inputs
metadata: Optional[Metadata] = (
None # omegaconf in python 3.9 does not backport annotations
)
graph_parallel_group_size: Optional[int] = None
def __post_init__(self) -> None:
self.run_dir = os.path.abspath(self.run_dir)
try:
try:
cluster = clusterscope.cluster()
except Exception as e:
# DCU 或受限环境下,clusterscope 可能调用 nvidia-smi 失败
cluster = os.environ.get("ONESCIENCE_CLUSTER", "sghpc")
if os.environ.get("LOCAL_RANK", "0") == "0":
print(
f"[WARN] clusterscope.cluster() failed "
f"({type(e).__name__}: {e}); fallback to '{cluster}'"
)
except RuntimeError:
cluster = ""
self.metadata = Metadata(
commit=get_commit_hash(),
log_dir=os.path.join(self.run_dir, self.timestamp_id, LOG_DIR_NAME),
checkpoint_dir=os.path.join(
self.run_dir, self.timestamp_id, CHECKPOINT_DIR_NAME
),
results_dir=os.path.join(self.run_dir, self.timestamp_id, RESULTS_DIR),
config_path=os.path.join(self.run_dir, self.timestamp_id, CONFIG_FILE_NAME),
preemption_checkpoint_dir=os.path.join(
self.run_dir,
self.timestamp_id,
CHECKPOINT_DIR_NAME,
PREEMPTION_STATE_DIR_NAME,
),
cluster_name=cluster,
)
def _set_seeds(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
def _set_deterministic_mode() -> None:
# this is required for full cuda deterministic mode
logging.info("Setting deterministic mode!")
os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
torch.use_deterministic_algorithms(True)
def _get_slurm_env() -> SlurmEnv:
slurm_job_env = SlurmJobEnvironment()
try:
slurm_env = SlurmEnv(
job_id=slurm_job_env.job_id,
raw_job_id=slurm_job_env.raw_job_id,
array_job_id=slurm_job_env.array_job_id,
array_task_id=slurm_job_env.array_task_id,
restart_count=os.environ.get("SLURM_RESTART_COUNT"),
)
except KeyError:
# slurm environment variables are undefined, running locally
slurm_env = SlurmEnv()
return slurm_env
def remove_runner_state_from_submission(log_folder: str, job_id: str) -> None:
# (HACK) Decouple the job from the runner state by manually modifying it
# this ensures the saved runner state is not re-submitted in the event of a node failure
# ie: if the job was started at state t=T, a requeue during node failure would resubmit the job
# starting at state t=T again without calling the checkpoint callback, losing all progress in between.
job_path = JobPaths(folder=log_folder, job_id=job_id)
if os.path.isfile(job_path.submitted_pickle):
submission_obj = DelayedSubmission.load(job_path.submitted_pickle)
submission_obj.args[0].job.runner_state_path = None
cloudpickle_dump(submission_obj, job_path.submitted_pickle)
class Submitit(Checkpointable):
def __init__(self) -> None:
self.config = None
self.runner = None
self.reducer = None
def __call__(
self, dict_config: DictConfig, run_type: RunType = RunType.RUN
) -> None:
self.config = dict_config
self.run_type = run_type
# modify the config metadata to add slurm info if they exist
self.config.job.metadata.slurm_env = _get_slurm_env()
setup_env_vars()
setup_logging()
dist_config = map_job_config_to_dist_config(self.config.job)
logging.info("Setting up distributed backend...")
distutils.setup(dist_config)
distutils.synchronize()
if (
distutils.is_master()
and self.config.job.scheduler.mode == SchedulerType.SLURM
):
# this pickle file is shared across all processes so can only modify this on the main rank
remove_runner_state_from_submission(
dict_config.job.metadata.log_dir,
self.config.job.metadata.slurm_env.job_id,
)
if self.config.job.graph_parallel_group_size is not None:
logging.info("Setting up graph parallel...")
gp_utils.setup_graph_parallel_groups(
self.config.job.graph_parallel_group_size,
dist_config["distributed_backend"],
)
self._init_logger()
print(f"rank:{distutils.get_rank()}")
_set_seeds(self.config.job.seed)
if self.config.job.deterministic:
_set_deterministic_mode()
if run_type == RunType.RUN:
logging.info("Calling runner.run() ...")
self.runner: Runner = hydra.utils.instantiate(self.config.runner)
self.runner.job_config = self.config.job
# must call resume state AFTER the runner has been initialized
self.runner.load_state(self.config.job.runner_state_path)
self.runner.run()
elif run_type == RunType.REDUCE:
logging.info("Calling reducer.reduce() ...")
self.reducer: Reducer = hydra.utils.instantiate(self.config.reducer)
self.reducer.job_config = self.config.job
self.reducer.runner_config = self.config.runner
# must call resume state AFTER the runner has been initialized
self.reducer.load_state(self.config.job.runner_state_path)
self.reducer.reduce()
else:
raise ValueError(f"run type {run_type} is not recognized!")
distutils.cleanup()
def _init_logger(self) -> None:
if (
self.config.job.logger
and distutils.is_master()
and not self.config.job.debug
and self.config.job.metadata.array_job_num == 0
):
# get a partial function from the config and instantiate wandb with it
# currently code assumes that we only use the WandBSingletonLogger
logger_initializer = hydra.utils.instantiate(self.config.job.logger)
simple_config = OmegaConf.to_container(
self.config, resolve=True, throw_on_missing=True
)
logger_initializer(
config=simple_config,
run_id=self.config.job.timestamp_id,
run_name=self.config.job.run_name,
log_dir=self.config.job.metadata.log_dir,
)
def checkpoint(self, *args, **kwargs) -> DelayedSubmission:
logging.error("Submitit checkpointing callback is triggered")
save_path = self.config.job.metadata.preemption_checkpoint_dir
cfg_copy = self.config.copy()
# only assign if the save was successful
cfg_copy.job.runner_state_path = None
if (
self.run_type == RunType.RUN
and self.runner.save_state(save_path, is_preemption=True)
) or (
self.run_type == RunType.REDUCE
and self.reducer.save_state(save_path, is_preemption=True)
):
cfg_copy.job.runner_state_path = save_path
if WandBSingletonLogger.initialized():
WandBSingletonLogger.get_instance().mark_preempting()
logging.info(
f"Submitit checkpointing callback is completed, resuming with use the following state: {save_path}"
)
return DelayedSubmission(Submitit(), cfg_copy)
def map_job_config_to_dist_config(job_cfg: JobConfig) -> dict:
scheduler_config = job_cfg.scheduler
return {
"world_size": scheduler_config.num_nodes * scheduler_config.ranks_per_node,
"distributed_backend": (
"gloo" if job_cfg.device_type == DeviceType.CPU else "nccl"
),
"submit": scheduler_config.mode == SchedulerType.SLURM,
"cpu": job_cfg.device_type == DeviceType.CPU,
"init_method": scheduler_config.distributed_init_method,
# for distributed shared file initialization
"shared_file_dir": os.path.join(job_cfg.run_dir, job_cfg.timestamp_id),
"array_job_num": job_cfg.metadata.array_job_num,
}
def get_canonical_config(config: DictConfig) -> DictConfig:
# manually initialize metadata, because OmegaConf currently doesn't call __post_init__ on dataclasses
job = OmegaConf.to_object(config.job)
job.__post_init__()
config.job = job
# check that each key other than the allowed top level keys are used in config
# find all top level keys are not in the allowed set
all_keys = set(config.keys()).difference(ALLOWED_TOP_LEVEL_KEYS)
used_keys = set()
for key in all_keys:
# make a copy of all keys except the key in question
copy_cfg = OmegaConf.create({k: v for k, v in config.items() if k != key})
try:
OmegaConf.resolve(copy_cfg)
except InterpolationKeyError:
# if this error is thrown, this means the key was actually required
used_keys.add(key)
unused_keys = all_keys.difference(used_keys)
if unused_keys != set():
raise ValueError(
f"Found unused keys in the config: {unused_keys}, please remove them!, only keys other than {ALLOWED_TOP_LEVEL_KEYS} or ones that are used as variables are allowed."
)
# resolve the config to fully replace the variables and delete all top level keys except for the ALLOWED_TOP_LEVEL_KEYS
for _k in ("ONESCIENCE_DATASETS_DIR", "ONESCIENCE_MODELS_DIR"):
if OmegaConf.select(config, _k, default=None) is None:
_v = os.environ.get(_k)
if _v:
OmegaConf.update(config, _k, _v, force_add=True)
OmegaConf.resolve(config)
return OmegaConf.create(
{k: v for k, v in config.items() if k in ALLOWED_TOP_LEVEL_KEYS}
)
def get_hydra_config_from_yaml(
config_yml: str, overrides_args: list[str]
) -> DictConfig:
# Load the configuration from the file
os.environ["HYDRA_FULL_ERROR"] = "1"
config_directory = os.path.dirname(os.path.abspath(config_yml))
config_name = os.path.basename(config_yml)
hydra.initialize_config_dir(config_directory, version_base="1.1")
cfg = hydra.compose(config_name=config_name, overrides=overrides_args)
# merge default structured config with initialized job object
cfg = OmegaConf.merge({"job": OmegaConf.structured(JobConfig)}, cfg)
# canonicalize config (remove top level keys that just used replacing variables)
return get_canonical_config(cfg)
def _runner_wrapper(config: DictConfig, run_type: RunType = RunType.RUN):
# This is needed when using elastic_launch for local runs since it looks for
# the __name__ attribute of the function, Submitit.__call__ does not have one
Submitit()(config, run_type)
def _run_under_torchrun(cfg: DictConfig) -> None:
"""在 torchrun / srun+torchrun 启动的 worker 进程里运行训练。
调用方已经是 rank N 之一(LOCAL_RANK 已由 torchrun 注入),因此这里
绝对不能再走 submitit 或 elastic_launch,而是直接:
1) setup_env_vars / setup_logging
2) DistributedManager.initialize() (读取 RANK/LOCAL_RANK/WORLD_SIZE 等 env 变量)
3) gp_utils (可选)
4) _set_seeds + deterministic
5) WandB 日志 (rank0)
6) hydra.utils.instantiate(cfg.runner) + runner.run()
7) DistributedManager.cleanup()
"""
setup_env_vars()
setup_logging()
world_size = int(os.environ.get("WORLD_SIZE", "1"))
logging.info(
f"Init distributed from ENV: RANK={os.environ.get('RANK')}, "
f"LOCAL_RANK={os.environ.get('LOCAL_RANK')}, WORLD_SIZE={world_size}, "
f"MASTER={os.environ.get('MASTER_ADDR')}:{os.environ.get('MASTER_PORT')}"
)
DistributedManager.initialize()
if cfg.job.graph_parallel_group_size is not None:
backend = "gloo" if cfg.job.device_type == DeviceType.CPU else "nccl"
gp_utils.setup_graph_parallel_groups(
cfg.job.graph_parallel_group_size, backend
)
_set_seeds(cfg.job.seed)
if cfg.job.deterministic:
_set_deterministic_mode()
dm = DistributedManager()
if cfg.job.logger and dm.rank == 0 and not cfg.job.debug:
logger_initializer = hydra.utils.instantiate(cfg.job.logger)
simple_config = OmegaConf.to_container(
cfg, resolve=True, throw_on_missing=True
)
logger_initializer(
config=simple_config,
run_id=cfg.job.timestamp_id,
run_name=cfg.job.run_name,
log_dir=cfg.job.metadata.log_dir,
)
runner = hydra.utils.instantiate(cfg.runner)
runner.job_config = cfg.job
runner.load_state(cfg.job.runner_state_path)
runner.run()
DistributedManager.cleanup()
def main(
args: argparse.Namespace | None = None, override_args: list[str] | None = None
):
if args is None:
parser = argparse.ArgumentParser()
parser.add_argument("-c", "--config", type=str, required=True)
args, override_args = parser.parse_known_args()
cfg = get_hydra_config_from_yaml(args.config, override_args)
log_dir = cfg.job.metadata.log_dir
os.makedirs(cfg.job.run_dir, exist_ok=True)
os.makedirs(log_dir, exist_ok=True)
OmegaConf.save(cfg, cfg.job.metadata.config_path)
logging.info(f"saved canonical config to {cfg.job.metadata.config_path}")
scheduler_cfg = cfg.job.scheduler
# ------------------------------------------------------------------
# torchrun / srun+torchrun worker 分支
# LOCAL_RANK 由 torchrun 注入, 说明我们已经在 rank N 的子进程中,
# 不应再走 submitit / elastic_launch (否则会递归 spawn N*N 个进程)。
# demo/run.sh 会把 LOCAL 调度与 torchrun 启动器组合使用, 进入这里。
# ------------------------------------------------------------------
if (
os.environ.get("LOCAL_RANK") is not None
and scheduler_cfg.mode == SchedulerType.LOCAL
):
_run_under_torchrun(cfg)
return
logging.info(f"Running fairchemv2 cli with {cfg}")
if scheduler_cfg.mode == SchedulerType.SLURM: # Run on cluster
assert (
os.getenv("SLURM_SUBMIT_HOST") is None
), "SLURM DID NOT SUBMIT JOB!! Please do not submit jobs from an active slurm job (srun or otherwise)"
executor = AutoExecutor(folder=log_dir, slurm_max_num_timeout=3)
executor.update_parameters(
name=cfg.job.run_name,
mem_gb=scheduler_cfg.slurm.mem_gb,
timeout_min=scheduler_cfg.slurm.timeout_hr * 60,
slurm_partition=scheduler_cfg.slurm.partition,
# gpus_per_node=scheduler_cfg.ranks_per_node,
gpus_per_node=None, # 不设置 gpus-per-node
cpus_per_task=scheduler_cfg.slurm.cpus_per_task,
tasks_per_node=scheduler_cfg.ranks_per_node,
nodes=scheduler_cfg.num_nodes,
slurm_qos=scheduler_cfg.slurm.qos,
slurm_account=scheduler_cfg.slurm.account,
# 新增:把 YAML 里的 additional_parameters 传给 sbatch
slurm_additional_parameters=getattr(
scheduler_cfg.slurm, "additional_parameters", None
),
)
if scheduler_cfg.num_array_jobs == 1:
job = executor.submit(Submitit(), cfg)
logging.info(
f"Submitted job id: {cfg.job.timestamp_id}, slurm id: {job.job_id}, logs: {cfg.job.metadata.log_dir}"
)
jobs = [job]
elif scheduler_cfg.num_array_jobs > 1:
executor.update_parameters(
slurm_array_parallelism=scheduler_cfg.num_array_jobs,
)
jobs = []
with executor.batch():
for job_number in range(scheduler_cfg.num_array_jobs):
_cfg = cfg.copy()
_cfg.job.metadata.array_job_num = job_number
job = executor.submit(Submitit(), _cfg)
jobs.append(job)
logging.info(f"Submitted {len(jobs)} jobs: {jobs[0].job_id.split('_')[0]}")
if "reducer" in cfg:
job_id = jobs[0].job_id.split("_")[0]
executor.update_parameters(
name=f"{cfg.job.run_name}_reduce",
# set a single node, or do we want the same config as the Runner or a separate JobConfig
nodes=1,
slurm_dependency=f"afterok:{job_id}",
slurm_additional_parameters={
"kill-on-invalid-dep": "yes"
}, # kill the reducer if run fails
)
executor.submit(Submitit(), cfg, RunType.REDUCE)
else:
from torch.distributed.launcher.api import LaunchConfig, elastic_launch
if scheduler_cfg.num_nodes > 1:
cfg.job.scheduler.num_nodes = 1
logging.warning(
f"You cannot use more than one node (scheduler_cfg.num_nodes={scheduler_cfg.num_nodes}) in LOCAL mode, over-riding to 1 node"
)
if scheduler_cfg.ranks_per_node > 1:
logging.info(
f"Running in local mode with {scheduler_cfg.ranks_per_node} ranks using device_type:{cfg.job.device_type}"
)
launch_config = LaunchConfig(
min_nodes=1,
max_nodes=1,
nproc_per_node=scheduler_cfg.ranks_per_node,
rdzv_backend="c10d",
max_restarts=0,
)
elastic_launch(launch_config, _runner_wrapper)(cfg)
if "reducer" in cfg:
elastic_launch(launch_config, _runner_wrapper)(cfg, RunType.REDUCE)
else:
logging.info("Running in local mode without elastic launch")
distutils.setup_env_local()
Submitit()(cfg)
if "reducer" in cfg:
Submitit()(cfg, RunType.REDUCE)
if __name__ == "__main__":
main()