Diff Coverage

Diff: origin/master...HEAD, staged and unstaged changes

Source File Diff Coverage (%) Missing Lines
hyper_parallel/trainer/base.py 42.9% 720,799,815,843
hyper_parallel/trainer/callbacks/base.py 73.4% 36-37,249,776,782,791,840-841,843-846,850,856,858,865,895-900,904,908-910,917-929,934,940,948,953-959,1048-1049,1059,1067,1071,1081,1084-1085,1091-1092,1095-1096,1104-1110,1114,1118,1132-1133,1156,1236,1238-1239,1247-1253,1259-1260,1267-1268,1275,1277-1278,1326,1340-1342,1357-1358,1393-1396,1402-1403,1407,1416
hyper_parallel/trainer/config.py 100%  
hyper_parallel/trainer/base.py
716
717
718
719
720
721
722
723
724
        self.tensorboard_callback = TensorBoardCallback(self)
        self.progress_callback = ProgressCallback(self)
        self.moe_monitor_callback = MoEMonitorCallback(self)
        # Health + operability (no-ops unless enabled in cfg.train.debug / .memory_monitor).
        self.training_state_monitor_callback = TrainingStateMonitorCallback(self)
        self.gradient_health_callback = GradientHealthCallback(self)
        self.memory_monitor_callback = MemoryMonitorCallback(self)
        self.gc_callback = GCCallback(self)
        # ``user_callbacks`` lets external code append extra Callback instances
795
796
797
798
799
800
801
802
803
        """Dispatch on_train_begin to all callbacks."""
        # Memory monitor first so it captures the truly-initial peak.
        self.memory_monitor_callback.on_train_begin(self.state)
        self.moe_monitor_callback.on_train_begin(self.state)
        self.training_state_monitor_callback.on_train_begin(self.state)
        self.profiler_callback.on_train_begin(self.state)
        self.wandb_callback.on_train_begin(self.state)
        self.tensorboard_callback.on_train_begin(self.state)
        # Checkpoint runs after log writers are armed and before progress so
811
812
813
814
815
816
817
818
819
        """Dispatch on_train_end to all callbacks."""
        self.checkpoint_callback.on_train_end(self.state)
        self.hf_export_callback.on_train_end(self.state)
        self.progress_callback.on_train_end(self.state)
        self.training_state_monitor_callback.on_train_end(self.state)
        self.tensorboard_callback.on_train_end(self.state)
        self.wandb_callback.on_train_end(self.state)
        self.profiler_callback.on_train_end(self.state)
        for cb in self.user_callbacks:
839
840
841
842
843
844
845
846
847

    def on_pre_optimizer_step(self, grad_norm=None):
        """Dispatch on_pre_optimizer_step (after grad clip, before optimizer.step)."""
        # Monitor first so an abnormal step is captured before health checks abort.
        self.training_state_monitor_callback.on_pre_optimizer_step(
            self.state, grad_norm=grad_norm,
        )
        self.gradient_health_callback.on_pre_optimizer_step(
            self.state, grad_norm=grad_norm,
hyper_parallel/trainer/callbacks/base.py
32
33
34
35
36
37
38
39
40
41

import torch
try:
    from torch.utils.tensorboard import SummaryWriter
except ImportError:
    SummaryWriter = None

from hyper_parallel import get_platform
from hyper_parallel.core.distributed_checkpoint import load as dcp_load, save as dcp_save
from hyper_parallel.core.distributed_checkpoint.offline_transform import (
245
246
247
248
249
250
251
252
253
        message = " | ".join(f"{k}={v}" for k, v in metrics.items())
        monitor_cb = getattr(self.trainer, 'training_state_monitor_callback', None)
        monitor_active = monitor_cb is not None and getattr(monitor_cb, 'active', False)
        if monitor_active:
            logger.info(message)
        else:
            logger.info_rank0(message)

        record = {
772
773
774
775
776
777
778
779
780
        super().__init__(trainer)
        train_cfg = getattr(trainer.args, "train", None)
        self.cfg = getattr(train_cfg, "monitor", None)
        if self.cfg is None:
            self.cfg = getattr(trainer.args, "monitor", None)

        self.enabled = bool(getattr(self.cfg, "monitor_on", False)) if self.cfg else False
        self.dump_path = getattr(self.cfg, "dump_path", "./dump") if self.cfg else "./dump"
        self.step_interval = int(getattr(self.cfg, "step_interval", 1) if self.cfg else 1)
778
779
780
781
782
783
784
785
786
        self.enabled = bool(getattr(self.cfg, "monitor_on", False)) if self.cfg else False
        self.dump_path = getattr(self.cfg, "dump_path", "./dump") if self.cfg else "./dump"
        self.step_interval = int(getattr(self.cfg, "step_interval", 1) if self.cfg else 1)
        if self.step_interval < 1:
            raise ValueError("train.monitor.step_interval must be >= 1.")

        self.local_loss_format = self._parse_formats("local_loss_format")
        self.device_local_loss_format = self._parse_formats("device_local_loss_format")
        self.local_norm_format = self._parse_formats("local_norm_format")
787
788
789
790
791
792
793
794
795
        self.device_local_norm_format = self._parse_formats("device_local_norm_format")

        raw_patterns = getattr(self.cfg, "target", None) if self.cfg else None
        if isinstance(raw_patterns, str):
            raw_patterns = [raw_patterns]
        self._target_patterns = (
            [re.compile(pattern) for pattern in raw_patterns]
            if raw_patterns else None
        )
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
        """Parse configured monitor output formats for a metric field."""
        value = getattr(self.cfg, field_name, None) if self.cfg else None
        if value is None:
            return ()
        if isinstance(value, str):
            formats = (value,)
        else:
            formats = tuple(value)
        unknown = sorted(set(formats) - self._SUPPORTED_FORMATS)
        if unknown:
            raise ValueError(
                f"train.monitor.{field_name} only supports "
                f"{sorted(self._SUPPORTED_FORMATS)}, got {unknown}."
            )
        return formats

    @staticmethod
    def _to_scalar(value) -> float:
        """Convert tensor-like values to a Python float."""
852
853
854
855
856
857
858
859
860
861
862
    @staticmethod
    def _to_scalar(value) -> float:
        """Convert tensor-like values to a Python float."""
        if value is None:
            return 0.0
        if hasattr(value, "to_local"):
            value = value.to_local()
        if hasattr(value, "detach"):
            value = value.detach()
        if hasattr(value, "float"):
            value = value.float()
861
862
863
864
865
866
867
868
869
        if hasattr(value, "float"):
            value = value.float()
        if hasattr(value, "item"):
            return float(value.item())
        return float(value)

    @staticmethod
    def _sanitize_tag(value: str) -> str:
        value = value.replace(".", "/").replace(" ", "_")
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
    def _write(self, formats: tuple[str, ...], tag: str, value: float,
               step: int, *, global_metric: bool = False) -> None:
        if not formats:
            return
        if "tensorboard" in formats:
            writer = self._global_writer if global_metric else self._rank_writer
            if writer is not None:
                writer.add_scalar(tag, value, step)
        if "log" in formats:
            self._pending_log_metrics[tag] = value

    @staticmethod
    def _format_monitor_value(value: float) -> str:
        return f"{float(value):.8f}"

    def _flush_step_log(self, step: int) -> None:
        """Print one compact rank-local monitor line for console output."""
        if not self._pending_log_metrics:
            return
        field_map = (
            ("loss", "loss/local_loss"),
            ("accum_loss", "loss/device_accum_local_loss"),
            ("grad_norm", "grad/device_local_norm"),
            ("grad_nan_count", "grad/device_nan_count"),
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
            ("grad_norm", "grad/device_local_norm"),
            ("grad_nan_count", "grad/device_nan_count"),
            ("grad_inf_count", "grad/device_inf_count"),
        )
        raw_tags = {tag for _, tag in field_map}
        parts = []
        for display_name, tag in field_map:
            if tag in self._pending_log_metrics:
                value = self._pending_log_metrics[tag]
                parts.append(f"{display_name}={self._format_monitor_value(value)}")
        for tag in sorted(self._pending_log_metrics):
            if tag in raw_tags:
                continue
            value = self._pending_log_metrics[tag]
            parts.append(f"{tag}={self._format_monitor_value(value)}")
        timestamp = time.strftime("%H:%M:%S")
        print(
            f"[{timestamp}][rank{self._rank}][INFO] local_state: "
            f"step={step} | " + " | ".join(parts),
            flush=True,
        )
        self._pending_log_metrics = {}

    def _abnormal_record_path(self, *, global_record: bool = False) -> str:
        configured = getattr(self.cfg, "abnormal_record_path", None) if self.cfg else None
        base_path = configured or os.path.join(self.dump_path, "abnormal_training_state.json")
936
937
938
939
940
941
942
943
    def _abnormal_record_path(self, *, global_record: bool = False) -> str:
        configured = getattr(self.cfg, "abnormal_record_path", None) if self.cfg else None
        base_path = configured or os.path.join(self.dump_path, "abnormal_training_state.json")
        if global_record:
            return base_path
        root, ext = os.path.splitext(base_path)
        suffix = ext or ".json"
        return f"{root}_rank_{self._rank}{suffix}"
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962

    def _record_abnormal(self, record: dict, *, global_record: bool = False) -> None:
        """Append an abnormal training-state record to the JSON log."""
        if global_record and self._rank != 0:
            return
        path = self._abnormal_record_path(global_record=global_record)
        os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
        records = []
        if os.path.isfile(path):
            try:
                with open(path, encoding="utf-8") as file:
                    loaded = json.load(file)
                if isinstance(loaded, list):
                    records = loaded
            except (OSError, json.JSONDecodeError):
                records = []
        records.append(record)
        with open(path, "w", encoding="utf-8") as file:
            json.dump(records, file, indent=2)
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053

    def _remove_grad_hooks(self) -> None:
        """Remove parameter backward hooks registered by the monitor."""
        for handle in self._grad_hook_handles:
            if hasattr(handle, "remove"):
                handle.remove()
        self._grad_hook_handles = []

    def _register_grad_hooks(self) -> None:
        """Register parameter hooks that capture pre-communication local grads."""
1055
1056
1057
1058
1059
1060
1061
1062
1063
                self.local_norm_format,
                self.device_local_norm_format,
                self.check_for_nan_in_loss_and_grad,
                self.embedding_local_norm_threshold is not None,))):
            return
        self._remove_grad_hooks()
        self._clear_hook_grad_stats()
        for name, param in self.trainer.model.named_parameters():
            if not self._should_record_param(name) or not getattr(param, "requires_grad", False):
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
            if not self._should_record_param(name) or not getattr(param, "requires_grad", False):
                continue
            register_hook = getattr(param, "register_hook", None)
            if not callable(register_hook):
                logger.warning(
                    "TrainingStateMonitor: parameter %s does not support register_hook, skip local grad monitor.",
                    name,
                )
                continue

            def hook_fn(grad, param_name=name):
                self._accumulate_hook_grad_stats(param_name, grad)
                return grad
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
            self._grad_hook_handles.append(register_hook(hook_fn))

    def on_train_begin(self, state: "TrainerState", **kwargs) -> None:
        if not (self.active or self.health_active):
            return
        self._rank = platform.get_rank()
        if self._uses_tensorboard():
            if SummaryWriter is None:
                raise RuntimeError(
                    "train.monitor requested tensorboard output, but "
                    "torch.utils.tensorboard.SummaryWriter is unavailable. "
                    "Install tensorboard or remove 'tensorboard' from monitor formats."
                )
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
                    "torch.utils.tensorboard.SummaryWriter is unavailable. "
                    "Install tensorboard or remove 'tensorboard' from monitor formats."
                )

            tb_root = os.path.join(self.dump_path, "tensorboard")
            self._rank_writer = SummaryWriter(
                os.path.join(tb_root, f"rank_{self._rank}")
            )
            if self._rank == 0:
                self._global_writer = SummaryWriter(os.path.join(tb_root, "global"))
        logger.info_rank0(
            "TrainingStateMonitor enabled: dump_path=%s step_interval=%d health=%s",
            self.dump_path, self.step_interval, self.health_active,
        )
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
        )
        self._register_grad_hooks()

    def on_train_end(self, state: "TrainerState", **kwargs) -> None:
        self._remove_grad_hooks()
        self._clear_hook_grad_stats()
        for writer in (self._rank_writer, self._global_writer):
            if writer is not None:
                writer.close()
        self._rank_writer = None
        self._global_writer = None

    def on_substep_end(self, state: "TrainerState", **kwargs) -> None:
        if not (self.active or self.health_active) or state.global_step % self.step_interval != 0:
            return
        substep_info = getattr(state, "substep_info", {})
        raw_loss = kwargs.get("raw_loss", substep_info.get("raw_loss"))
        if raw_loss is None:
            return
        micro_step = kwargs.get("micro_step", substep_info.get("micro_step", 0))
        if int(micro_step) == 0:
            self._step_loss_sum = 0.0
            self._step_loss_count = 0
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
        self.last_local_loss = self._step_loss_sum / self._step_loss_count
        self.last_monitor_step = state.global_step

        self._check_nonfinite(state, "local_loss", loss_value)
        tag = "loss/local_loss"
        self._write(
            self.local_loss_format,
            tag,
            loss_value,
            state.global_step,
1152
1153
1154
1155
1156
1157
1158
1159
1160
            if not self._should_record_param(name):
                continue
            stats = self._hook_grad_stats.get(name)
            if stats is None:
                continue
            sum_sq = float(stats["sum_sq"])
            norm = math.sqrt(sum_sq)
            abs_max = float(stats["abs_max"])
            nan_count = int(stats["nan_count"])
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
                )

    def on_pre_optimizer_step(self, state: "TrainerState", **kwargs) -> None:
        if not (self.active or self.health_active):
            return
        if state.global_step % self.step_interval != 0:
            self._clear_hook_grad_stats()
            return
        try:
            self._record_hook_grad_stats(state)
        finally:
            self._clear_hook_grad_stats()
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
            self._clear_hook_grad_stats()

    def on_step_end(self, state: "TrainerState", *, loss: Optional[float] = None,
                    grad_norm: Optional[float] = None, **kwargs) -> None:
        if not (self.active or self.health_active):
            return
        if state.global_step % self.step_interval == 0:
            if self._step_loss_count > 0:
                device_loss = self._step_loss_sum / self._step_loss_count
                self._check_nonfinite(state, "device_accum_local_loss", device_loss)
                self._write(
                    self.device_local_loss_format,
                    "loss/device_accum_local_loss",
                    device_loss,
                    state.global_step,
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
                    "loss/device_accum_local_loss",
                    device_loss,
                    state.global_step,
                )
            if self._rank == 0 and loss is not None:
                self._write(
                    ("tensorboard",) if self._uses_tensorboard() else (),
                    "loss/global_loss",
                    float(loss),
                    state.global_step,
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
                    float(loss),
                    state.global_step,
                    global_metric=True,
                )
            if self._rank == 0 and grad_norm is not None:
                self._write(
                    ("tensorboard",) if self._uses_tensorboard() else (),
                    "grad/global_grad_norm",
                    float(grad_norm),
                    state.global_step,
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
                    float(grad_norm),
                    state.global_step,
                    global_metric=True,
                )
            self._flush_step_log(state.global_step)

        self._step_loss_sum = 0.0
        self._step_loss_count = 0


class GradientHealthCallback(Callback):
    """Detect NaN / Inf grad_norm and raise / warn.
1322
1323
1324
1325
1326
1327
1328
1329
1330
            int(getattr(monitor_cfg, "global_norm_spike_count_threshold", 1))
            if monitor_cfg is not None else 1
        )
        if self.global_norm_spike_count_threshold < 1:
            raise ValueError("train.monitor.global_norm_spike_count_threshold must be >= 1.")
        self._global_norm_spike_count = 0
        self.monitor_cfg = monitor_cfg

    def _abnormal_record_path(self, *, global_record: bool = False) -> str:
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
        )
        base_path = configured or os.path.join(dump_path, "abnormal_training_state.json")
        if global_record:
            return base_path
        root, ext = os.path.splitext(base_path)
        suffix = ext or ".json"
        return f"{root}_rank_{platform.get_rank()}{suffix}"

    def _record_abnormal(self, record: dict, *, global_record: bool = False) -> None:
        """Append an abnormal gradient-health record to the JSON log."""
        if global_record and platform.get_rank() != 0:
1353
1354
1355
1356
1357
1358
1359
1360
1361
                with open(path, encoding="utf-8") as file:
                    loaded = json.load(file)
                if isinstance(loaded, list):
                    records = loaded
            except (OSError, json.JSONDecodeError):
                records = []
        records.append(record)
        with open(path, "w", encoding="utf-8") as file:
            json.dump(records, file, indent=2)
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
            self._check_global_norm_spike(state, grad_norm)

    def on_step_end(self, state: "TrainerState", *, loss: Optional[float] = None,
                    grad_norm: Optional[float] = None, **kwargs) -> None:
        if not self.nan_inf_check_enabled or loss is None:
            return
        if math.isnan(loss) or math.isinf(loss):
            record = {
                "step": state.global_step,
                "rank": platform.get_rank(),
                "metric": "loss",
                "value": loss,
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
                "rank": platform.get_rank(),
                "metric": "loss",
                "value": loss,
            }
            self._record_abnormal(record)
            logger.error(
                "GradientHealthCallback: loss=%s at step %d (NaN/Inf).",
                loss, state.global_step,
            )
            raise RuntimeError(
                f"Non-finite loss={loss} at step {state.global_step}. "
                "Disable train.monitor.check_for_nan_in_loss_and_grad or "
                "train.debug.check_nan_inf to skip this guard."
            )
1412
1413
1414
1415
1416
1417
1418
1419
1420

    def _check_global_norm_spike(self, state: "TrainerState", grad_norm: float) -> None:
        """Check whether global grad norm exceeds the configured spike threshold."""
        if not self.global_norm_spike_enabled:
            return
        threshold = self.global_norm_spike_threshold
        if grad_norm >= threshold:
            self._global_norm_spike_count += 1
            record = {