Skip to content

Commit c17ad41

Browse files
Implement EMA of the model (ecmwf#1005)
* Save current state * Save current state * Barebone FSDP2 prototype TODO save checkpoints * First version of saving model * Fix save_model * Log everything and log to files * Remove redundant path creation * Allow for both slurm and torchrun + fewer log files * Cleaning up init_ddp * Ruff * Attempt to avoid duplicate logging * FSDP2 with mixed precision policy * Ruff * Clean up and logging * Try to get loggers to behave as we want * Makes ruff unhappy but works * Fixed ruff issue * Fixed problems with multi-node training. * Fix for interactive/non-DDP runs * No idea why, but this seems to work so far Committing simply so it is saved, obviously needs cleanup * Still works! So which is it memory or the grad scaler? * Also still works, I now strongly suspect the amp.gradscaler * This still works, I have no clue anymore why but whatever it works now.... * Enable loading model from absolute paths * Enable loading for 1 GPU only * Fix 1 GPU train continue * Appease ruff * Fix saving the model more regularly and perf logging * Fixed problem when training with 2 nodes. * Fix data loader seed * Appease ruff * Shouldn't overwrite with_fsdp like this * Potential fix for FSDP2 issue with different ranks using different model parts * Fix loss scaling and logging of dummy data loss * Clean up * Appease ruff * Start implementing EMA, works for 1 GPU * Make EMA model multi-gpu compatible * Fix linting issues * Address comments on PR * Enforce the ema model to strictly be the same as model Note this likely needs to be changed for student-teacher training * Rename variable --------- Co-authored-by: Christian Lessig <christian.lessig@ecmwf.int>
1 parent 1577b39 commit c17ad41

6 files changed

Lines changed: 188 additions & 121 deletions

File tree

config/default_config.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ batch_size_validation_per_gpu: 1
8686
# encoders and decoders that exist per stream have the stream name attached at the end
8787
freeze_modules: ""
8888

89+
# whether to track the exponential moving average of weights for validation
90+
validate_with_ema: True
91+
ema_ramp_up_ratio: 0.09
92+
ema_halflife_in_thousands: 1e-3
93+
8994
# training mode: "forecast" or "masking" (masked token modeling)
9095
# for "masking" to train with auto-encoder mode, forecast_offset should be 0
9196
training_mode: "masking"

packages/common/src/weathergen/common/config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ def _get_model_config_file_name(run_id: str, epoch: int | None):
100100
epoch_str = f"_epoch{epoch:05d}"
101101
return f"model_{run_id}{epoch_str}.json"
102102

103+
103104
def get_model_results(run_id: str, epoch: int, rank: int) -> Path:
104105
"""
105106
Get the path to the model results zarr store from a given run_id and epoch.
@@ -110,6 +111,7 @@ def get_model_results(run_id: str, epoch: int, rank: int) -> Path:
110111
raise FileNotFoundError(f"Zarr file {zarr_path} does not exist or is not a directory.")
111112
return zarr_path
112113

114+
113115
def _apply_fixes(config: Config) -> Config:
114116
"""
115117
Apply fixes to maintain a best effort backward combatibility.
@@ -129,12 +131,13 @@ def _check_logging(config: Config) -> Config:
129131
"""
130132
config = config.copy()
131133
if config.get("train_log_freq") is None: # TODO remove this for next version
132-
config.train_log_freq = OmegaConf.construct(
134+
config.train_log_freq = OmegaConf.create(
133135
{"checkpoint": 250, "terminal": 10, "metrics": config.train_log.log_interval}
134136
)
135137

136138
return config
137139

140+
138141
def load_config(
139142
private_home: Path | None,
140143
from_run_id: str | None,

packages/common/src/weathergen/common/io.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
import logging
1414
import pathlib
1515
import typing
16+
from copy import deepcopy
1617

18+
import astropy_healpix as hp
1719
import dask.array as da
1820
import numpy as np
1921
import xarray as xr
@@ -65,6 +67,45 @@ def create(cls, other: typing.Any) -> "IOReaderData":
6567

6668
return cls(**dataclasses.asdict(other))
6769

70+
@classmethod
71+
def spoof(
72+
cls, other: typing.Any, nchannels, datetime, geoinfo_size, mean_of_data
73+
) -> typing.Self:
74+
"""
75+
Spoof an instance from data_reader_base.ReaderData instance.
76+
other should be such an instance.
77+
"""
78+
79+
hl = 5
80+
dx = 0.5
81+
dy = 0.5
82+
other_copy = deepcopy(other)
83+
num_healpix_cells = 12 * 4**hl
84+
lons, lats = hp.healpix_to_lonlat(
85+
np.arange(0, num_healpix_cells), 2**hl, dx=dx, dy=dy, order="nested"
86+
)
87+
other_copy.coords = np.stack([lats.deg, lons.deg], axis=-1, dtype=np.float32)
88+
other_copy.geoinfos = np.zeros((other_copy.coords.shape[0], geoinfo_size), dtype=np.float32)
89+
90+
other_copy.data = np.expand_dims(mean_of_data.astype(np.float32), axis=0).repeat(
91+
other_copy.coords.shape[0], axis=0
92+
)
93+
other_copy.datetimes = np.array(datetime).repeat(other_copy.coords.shape[0])
94+
95+
n_datapoints = len(other_copy.data)
96+
97+
assert other_copy.coords.shape == (n_datapoints, 2), (
98+
"number of datapoints do not match data"
99+
)
100+
assert other_copy.geoinfos.shape[0] == n_datapoints, (
101+
"number of datapoints do not match data"
102+
)
103+
assert other_copy.datetimes.shape[0] == n_datapoints, (
104+
"number of datapoints do not match data"
105+
)
106+
107+
return cls(**dataclasses.asdict(other_copy))
108+
68109

69110
@dataclasses.dataclass
70111
class ItemKey:

packages/evaluate/src/weathergen/evaluate/export_inference.py

Lines changed: 22 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,7 @@
3131

3232
if not _logger.handlers:
3333
handler = logging.StreamHandler()
34-
formatter = logging.Formatter(
35-
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
36-
)
34+
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
3735
handler.setFormatter(formatter)
3836
_logger.addHandler(handler)
3937

@@ -98,9 +96,9 @@ def reshape_dataset(input_data_array: xr.DataArray) -> xr.Dataset:
9896
ipoint=input_data_array.coords["ipoint"],
9997
pressure_level=pl,
10098
)
101-
reshaped_dataset = reshaped_dataset.set_index(
102-
ipoint=("valid_time", "lat", "lon")
103-
).unstack("ipoint")
99+
reshaped_dataset = reshaped_dataset.set_index(ipoint=("valid_time", "lat", "lon")).unstack(
100+
"ipoint"
101+
)
104102
return reshaped_dataset
105103

106104

@@ -120,9 +118,8 @@ def add_conventions(stream: str, run_id: str, ds: xr.Dataset) -> xr.Dataset:
120118
ds.attrs["title"] = f"WeatherGenerator Output for {run_id} using stream {stream}"
121119
ds.attrs["institution"] = "WeatherGenerator Project"
122120
ds.attrs["source"] = "WeatherGenerator v0.0"
123-
ds.attrs["history"] = (
124-
"Created using the zarr_nc.py script on "
125-
+ np.datetime_as_string(np.datetime64("now"), unit="s")
121+
ds.attrs["history"] = "Created using the zarr_nc.py script on " + np.datetime_as_string(
122+
np.datetime64("now"), unit="s"
126123
)
127124
ds.attrs["Conventions"] = "CF-1.12"
128125
return ds
@@ -172,9 +169,7 @@ def cf_parser(config: OmegaConf, ds: xr.Dataset) -> xr.Dataset:
172169
if mapping[var_name]["level_type"] == "sfc":
173170
dims.remove("pressure")
174171
coordinates = {}
175-
for coord, new_name in config["coordinates"][
176-
mapping[var_name]["level_type"]
177-
].items():
172+
for coord, new_name in config["coordinates"][mapping[var_name]["level_type"]].items():
178173
coordinates |= {
179174
new_name: (
180175
ds.coords[coord].dims,
@@ -257,7 +252,7 @@ def get_data(
257252
dtype: str,
258253
fsteps: list,
259254
channels: list,
260-
fstep_hours: int,
255+
fstep_hours: int,
261256
n_processes: list,
262257
epoch: int,
263258
rank: int,
@@ -295,11 +290,7 @@ def get_data(
295290
all_channels = dummy_out.target.channels
296291
channels = all_channels if channels is None else channels
297292

298-
fsteps = (
299-
zio_forecast_steps
300-
if fsteps is None
301-
else sorted([int(fstep) for fstep in fsteps])
302-
)
293+
fsteps = zio_forecast_steps if fsteps is None else sorted([int(fstep) for fstep in fsteps])
303294

304295
samples = (
305296
zio_samples
@@ -310,8 +301,7 @@ def get_data(
310301
for sample_idx in tqdm(samples):
311302
da_fs = []
312303
step_tasks = [
313-
(sample_idx, fstep, run_id, stream, dtype, epoch, rank)
314-
for fstep in fsteps
304+
(sample_idx, fstep, run_id, stream, dtype, epoch, rank) for fstep in fsteps
315305
]
316306
for result in tqdm(
317307
pool.imap_unordered(get_data_worker, step_tasks, chunksize=1),
@@ -323,9 +313,7 @@ def get_data(
323313
result = result.as_xarray().squeeze()
324314
if set(channels) != set(all_channels):
325315
available_channels = result.channel.values
326-
existing_channels = [
327-
ch for ch in channels if ch in available_channels
328-
]
316+
existing_channels = [ch for ch in channels if ch in available_channels]
329317
if len(existing_channels) < len(channels):
330318
_logger.info(
331319
f"The following channels were not found: "
@@ -340,7 +328,7 @@ def get_data(
340328
_logger.info(
341329
f"Saving sample {sample_idx} data to {output_format} format in {output_dir}."
342330
)
343-
331+
344332
save_sample_to_netcdf(
345333
str(dtype)[:4],
346334
da_fs,
@@ -383,9 +371,7 @@ def save_sample_to_netcdf(
383371
Loaded config for cf_parser function.
384372
"""
385373
# find forecast_ref_time
386-
frt = array_list[0].valid_time.values[0] - fstep_hours * int(
387-
array_list[0].forecast_step.values
388-
)
374+
frt = array_list[0].valid_time.values[0] - fstep_hours * int(array_list[0].forecast_step.values)
389375
out_fname = output_filename(type_str, run_id, output_dir, output_format, frt)
390376
# check if file already exists
391377
if out_fname.exists():
@@ -406,9 +392,7 @@ def save_sample_to_netcdf(
406392
sample_all_steps = cf_parser(config, sample_all_steps)
407393
# add forecast_period attributes
408394
n_hours = fstep_hours.astype("int64")
409-
sample_all_steps["forecast_period"] = (
410-
sample_all_steps["forecast_period"] * n_hours
411-
)
395+
sample_all_steps["forecast_period"] = sample_all_steps["forecast_period"] * n_hours
412396
sample_all_steps["forecast_period"].attrs = {
413397
"standard_name": "forecast_period",
414398
"long_name": "time since forecast_reference_time",
@@ -495,11 +479,11 @@ def parse_args(args: list) -> argparse.Namespace:
495479
)
496480

497481
parser.add_argument(
498-
"--fstep-hours",
499-
type = int,
500-
default= 6,
501-
help= "Time difference between forecast steps in hours (e.g., 6)"
502-
)
482+
"--fstep-hours",
483+
type=int,
484+
default=6,
485+
help="Time difference between forecast steps in hours (e.g., 6)",
486+
)
503487

504488
parser.add_argument(
505489
"--epoch",
@@ -535,7 +519,7 @@ def export_from_args(args: list) -> None:
535519
Export data from Zarr store to NetCDF files based on command line arguments.
536520
Parameters
537521
----------
538-
args : List of command line arguments.
522+
args : List of command line arguments.
539523
"""
540524
args = parse_args(sys.argv[1:])
541525
run_id = args.run_id
@@ -559,7 +543,7 @@ def export_from_args(args: list) -> None:
559543
config_file = Path(_REPO_ROOT, "config/evaluate/config_zarr2cf.yaml")
560544
config = OmegaConf.load(config_file)
561545
# check config loaded correctly
562-
assert len(config["variables"].keys()) > 0 , "Config file not loaded correctly"
546+
assert len(config["variables"].keys()) > 0, "Config file not loaded correctly"
563547

564548
for dtype in data_type:
565549
_logger.info(f"Starting processing {dtype} for run ID {run_id}.")
@@ -570,7 +554,7 @@ def export_from_args(args: list) -> None:
570554
dtype,
571555
fsteps,
572556
channels,
573-
fstep_hours,
557+
fstep_hours,
574558
n_processes,
575559
epoch,
576560
rank,

src/weathergen/model/ema.py

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,52 +8,64 @@
88
# nor does it submit to any jurisdiction.
99

1010

11-
import copy
12-
1311
import torch
1412

1513

16-
class EMA:
14+
class EMAModel:
1715
"""
1816
Taken and modified from https://github.com/NVlabs/edm2/tree/main
1917
"""
2018

2119
@torch.no_grad()
22-
def __init__(self, net, halflife_steps=float("inf"), rampup_ratio=0.09):
23-
self.net = net
20+
def __init__(
21+
self,
22+
model,
23+
empty_model,
24+
halflife_steps=float("inf"),
25+
rampup_ratio=0.09,
26+
is_model_sharded=False,
27+
):
28+
self.original_model = model
2429
self.halflife_steps = halflife_steps
2530
self.rampup_ratio = rampup_ratio
26-
self.ema = copy.deepcopy(net)
31+
self.ema_model = empty_model
32+
self.is_model_sharded = is_model_sharded
33+
34+
self.reset()
2735

2836
@torch.no_grad()
2937
def reset(self):
30-
for p_net, p_ema in zip(self.net.parameters(), self.ema.parameters(), strict=False):
31-
p_ema.copy_(p_net)
38+
"""
39+
This function resets the EMAModel to be the same as the Model.
40+
41+
It operates via the state_dict to be able to deal with sharded tensors in case
42+
FSDP2 is used.
43+
"""
44+
self.ema_model.to_empty(device="cuda")
45+
maybe_sharded_sd = self.original_model.state_dict()
46+
# this copies correctly tested in pdb
47+
mkeys, ukeys = self.ema_model.load_state_dict(maybe_sharded_sd, strict=True, assign=False)
3248

3349
@torch.no_grad()
34-
def update(self, cur_steps, batch_size):
50+
def update(self, cur_step, batch_size):
3551
halflife_steps = self.halflife_steps
3652
if self.rampup_ratio is not None:
37-
halflife_steps = min(halflife_steps, cur_steps / 1e3 * self.rampup_ratio)
53+
halflife_steps = min(halflife_steps, cur_step / 1e3 * self.rampup_ratio)
3854
beta = 0.5 ** (batch_size / max(halflife_steps * 1e3, 1e-6))
39-
for p_net, p_ema in zip(self.net.parameters(), self.ema.parameters(), strict=False):
55+
for p_net, p_ema in zip(
56+
self.original_model.parameters(), self.ema_model.parameters(), strict=True
57+
):
4058
p_ema.lerp_(p_net, 1 - beta)
4159

4260
@torch.no_grad()
4361
def forward_eval(self, *args, **kwargs):
44-
self.ema.eval()
45-
out = self.ema(*args, **kwargs)
46-
self.ema.train()
62+
self.ema_model.eval()
63+
out = self.ema_model(*args, **kwargs)
64+
self.ema_model.train()
4765
return out
4866

49-
@torch.no_grad()
50-
def get(self):
51-
for p_net, p_ema in zip(self.net.buffers(), self.ema.buffers(), strict=False):
52-
p_ema.copy_(p_net)
53-
return self.ema
54-
5567
def state_dict(self):
56-
return self.ema.state_dict()
68+
return self.ema_model.state_dict()
5769

58-
def load_state_dict(self, state):
59-
self.ema.load_state_dict(state)
70+
def load_state_dict(self, state, **kwargs):
71+
self.ema_model.load_state_dict(state, **kwargs)

0 commit comments

Comments
 (0)