Skip to content

Commit 93bca31

Browse files
committed
Separate prognostic and boundary tensors end-to-end
Thread prognostic and boundary inputs as separate tensors through the data pipeline, stepper, base model, and all model implementations. Previously they were concatenated along the channel dim before reaching the model; now each model's forward_once receives (prog, boundary, ctx). FOMO concatenates before its single-stream encoder (the dual-perceiver encoder that enables cross-resolution fusion lands in a follow-up). Samudra and FOMini concatenate inside their forward_once as before. Also includes unrelated changes from previously-merged PRs that were on this branch: DataLoadingConfig (#668), Nsight profiling (#674), samudra highres configs (#679), torch profiling improvements (#677). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> # Conflicts: # src/ocean_emulators/stepper.py # tests/test_stepper.py
1 parent 2aa8f96 commit 93bca31

17 files changed

Lines changed: 350 additions & 273 deletions

src/ocean_emulators/config.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,8 @@ class BaseModelConfig(BaseConfig, abc.ABC):
665665
@abc.abstractmethod
666666
def build(
667667
self,
668-
in_channels: int,
668+
prog_channels: int,
669+
boundary_channels: int,
669670
out_channels: int,
670671
hist: int,
671672
static_data_for_corrector: xr.Dataset | None,
@@ -688,7 +689,8 @@ class SamudraConfig(BaseModelConfig):
688689

689690
def build(
690691
self,
691-
in_channels: int,
692+
prog_channels: int,
693+
boundary_channels: int,
692694
out_channels: int,
693695
hist: int,
694696
static_data_for_corrector: xr.Dataset | None,
@@ -704,6 +706,7 @@ def build(
704706
corrector = self.corrector.build(
705707
hist, src.spherical_area_weights, static_data_for_corrector
706708
)
709+
in_channels = prog_channels + boundary_channels
707710
total_in_channels = (
708711
in_channels + self.pos_channels + (3 if self.add_3d_coordinates else 0)
709712
)
@@ -751,7 +754,8 @@ class FOMOConfig(BaseModelConfig):
751754

752755
def build(
753756
self,
754-
in_channels: int,
757+
prog_channels: int,
758+
boundary_channels: int,
755759
out_channels: int,
756760
hist: int,
757761
static_data_for_corrector: xr.Dataset | None,
@@ -773,8 +777,16 @@ def build(
773777
"Please set `use_bfloat16=True` or `perceiver_implementation='naive'`."
774778
)
775779

780+
in_channels = prog_channels + boundary_channels
781+
total_in_channels = in_channels + (3 if self.add_3d_coordinates else 0)
782+
776783
encoder = self.encoder.build(
777-
in_channels, self.embedding_dim, extent, max_lat_size, max_lon_size, impl
784+
total_in_channels,
785+
self.embedding_dim,
786+
extent,
787+
max_lat_size,
788+
max_lon_size,
789+
impl,
778790
)
779791
processor = self.processor.build(
780792
self.embedding_dim,
@@ -788,7 +800,6 @@ def build(
788800
impl,
789801
)
790802

791-
total_in_channels = in_channels + (3 if self.add_3d_coordinates else 0)
792803
add_3d_coordinates = Concat3dCoordinates() if self.add_3d_coordinates else None
793804
return FOMO(
794805
in_channels=total_in_channels,
@@ -838,7 +849,8 @@ class FOMiniConfig(BaseModelConfig):
838849

839850
def build(
840851
self,
841-
in_channels: int,
852+
prog_channels: int,
853+
boundary_channels: int,
842854
out_channels: int,
843855
hist: int,
844856
static_data_for_corrector: xr.Dataset | None,
@@ -857,6 +869,7 @@ def build(
857869
"Please set `use_bfloat16=True` or `perceiver_implementation='naive'`."
858870
)
859871

872+
in_channels = prog_channels + boundary_channels
860873
perceiver_io = self.perceiver.build_io(
861874
self.embedding_dim,
862875
self.queries_dim,

src/ocean_emulators/constants.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
# So, we'll leave this default and use symbolic axes locally.
3030
type Input = Float[Grid, "*batch total_vars"]
3131

32-
Example = tuple[Input, Prognostic]
32+
Example = tuple[
33+
Prognostic, Boundary, Prognostic
34+
] # (prognostic_input, boundary_input, label)
3335

3436
GridMask = Bool[Tensor, "lat lon"]
3537
PrognosticMask = Bool[GridMask, "prognostic_vars"]

src/ocean_emulators/datasets.py

Lines changed: 63 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from xarray_einstats.einops import rearrange as xr_rearrange # noqa: F401
1414

1515
from ocean_emulators.constants import (
16+
Boundary,
1617
BoundaryVarNames,
1718
Example,
1819
GridMask,
@@ -138,9 +139,9 @@ def inference_target(self, step: int | slice):
138139
label = self._get_label(x_index)
139140
return label
140141

141-
def get_initial_input(self):
142-
data = self.__getitem__(0)[0]
143-
return data
142+
def get_initial_input(self) -> tuple[Prognostic, Boundary]:
143+
prog, boundary, _ = self.__getitem__(0)
144+
return prog, boundary
144145

145146
def get_target_time(self, start_step: int, num_steps: int):
146147
x_index = self._get_x_index(start_step)
@@ -154,20 +155,28 @@ def get_target_time(self, start_step: int, num_steps: int):
154155
)
155156
)
156157

157-
def merge_prognostic_and_boundary(self, prognostic: torch.Tensor, step: int):
158+
def get_boundary_for_prognostic(
159+
self, prognostic: Prognostic, step: int
160+
) -> tuple[Prognostic, Boundary]:
161+
"""Return ``(prognostic, boundary)`` at the requested step.
162+
163+
Prognostic is passed through verbatim; boundary is fetched from the
164+
boundary source and returned on the same device as ``prognostic``. The
165+
two tensors may live on different spatial grids once the cross-
166+
resolution boundary source lands — the encoder is responsible for
167+
fusing them.
168+
"""
158169
x_index = self._get_x_index(step)
159170
boundary = self._get_boundary(x_index).to(prognostic.device)
160-
data = torch.cat((prognostic, boundary), dim=1)
161-
return data
171+
return prognostic, boundary
162172

163173
@elapsed(level=logging.DEBUG)
164174
def __getitem__(self, idx):
165175
x_index = self._get_x_index(idx)
166-
data_in = self._get_prognostic(x_index)
176+
data_in_prog = self._get_prognostic(x_index)
167177
data_in_boundary = self._get_boundary(x_index)
168-
data_in = torch.cat((data_in, data_in_boundary), dim=1)
169178
label = self._get_label(x_index)
170-
return (data_in, label)
179+
return (data_in_prog, data_in_boundary, label)
171180

172181
def _get_x_index(self, idx):
173182
if isinstance(idx, slice):
@@ -357,44 +366,40 @@ def pin_memory(self):
357366
class TrainData:
358367
"""A single batch of training data.
359368
360-
A single batch contains multiple steps worth of `Example`s (i.e., input/output pairs). These steps are used during
361-
autoregressive rollout in the training and inference process.
362-
363-
Constraint: The `Input` tensor is a combination of (flattened) prognostic variables (at all depth levels) and
364-
boundary forcings. The top `num_prognostic_channels` number of channels must be prognostic variables whereas the
365-
remaining bottom channels are boundary forcings.
369+
A single batch contains multiple steps worth of ``Example`` entries, each
370+
of which is a ``(prognostic_input, boundary_input, label)`` triple. The
371+
prognostic and boundary tensors are carried *separately* (they are no
372+
longer concatenated along the channel dim) so that the encoder can fuse
373+
them at the token level, potentially at different spatial resolutions.
366374
"""
367375

368-
def __init__(self, num_prognostic_channels: int, ctx: GridContext):
376+
def __init__(
377+
self, num_prognostic_channels: int, num_boundary_channels: int, ctx: GridContext
378+
):
369379
self.num_prognostic_channels = num_prognostic_channels
380+
self.num_boundary_channels = num_boundary_channels
370381
self.ctx = ctx
371382
self.example_by_step: list[Example] = []
372383
self.load_stats: LoadStats | None = None
373384

374-
def append(self, input_: Input, label: Prognostic):
385+
def append(
386+
self, prognostic_input: Prognostic, boundary_input: Boundary, label: Prognostic
387+
) -> None:
375388
"""Add another Example as a new step."""
376-
self.example_by_step.append((input_, label))
389+
self.example_by_step.append((prognostic_input, boundary_input, label))
377390

378-
def get_initial_input(self) -> Input:
391+
def get_initial_input(self) -> tuple[Prognostic, Boundary]:
379392
return self.get_input(0)
380393

381-
def get_input(self, step: int) -> Input:
382-
return self[step][0]
394+
def get_input(self, step: int) -> tuple[Prognostic, Boundary]:
395+
prog, boundary, _ = self.example_by_step[step]
396+
return prog, boundary
383397

384398
def get_label(self, step: int) -> Prognostic:
385-
return self[step][1]
386-
387-
def merge_prognostic_and_boundary(self, prognostic: torch.Tensor, step: int):
388-
input_ = self.get_input(step)
389-
merged = input_.clone()
390-
merged[:, : self.num_prognostic_channels] = prognostic
391-
return merged
392-
393-
def values(self):
394-
return self.example_by_step
399+
return self.example_by_step[step][2]
395400

396401
def __getitem__(self, step: int) -> Example:
397-
"""Converts index (step) into (data, label) tuple."""
402+
"""Converts index (step) into (prognostic, boundary, label) triple."""
398403
return self.example_by_step[step]
399404

400405
def __len__(self) -> int:
@@ -405,16 +410,20 @@ def __iter__(self):
405410

406411
def to(self, device: torch.device) -> None:
407412
for step in self:
413+
prog, boundary, label = self.example_by_step[step]
408414
self.example_by_step[step] = (
409-
self[step][0].to(device, non_blocking=True),
410-
self[step][1].to(device, non_blocking=True),
415+
prog.to(device, non_blocking=True),
416+
boundary.to(device, non_blocking=True),
417+
label.to(device, non_blocking=True),
411418
)
412419

413420
def pin_memory(self):
414421
for step in self:
422+
prog, boundary, label = self.example_by_step[step]
415423
self.example_by_step[step] = (
416-
self[step][0].pin_memory(),
417-
self[step][1].pin_memory(),
424+
prog.pin_memory(),
425+
boundary.pin_memory(),
426+
label.pin_memory(),
418427
)
419428
return self
420429

@@ -484,6 +493,7 @@ def __init__(
484493
self._concurrent_compute = concurrent_compute_
485494

486495
self.num_prognostic_channels: int = (hist + 1) * len(prognostic_var_names)
496+
self.num_boundary_channels: int = (hist + 1) * len(boundary_var_names)
487497
assert np.array_equal(srcs[0].data.time, srcs[-1].data.time), (
488498
"src and dst DataSource have different time slices!"
489499
)
@@ -605,9 +615,13 @@ def to_train_data(
605615
Returns:
606616
TrainData with tensors on the target device
607617
"""
608-
train_data = TrainData(self.num_prognostic_channels, self.ctx.to(device))
618+
train_data = TrainData(
619+
self.num_prognostic_channels,
620+
self.num_boundary_channels,
621+
self.ctx.to(device),
622+
)
609623
for input_, boundary, label in raw_train_data.raw_data:
610-
input_, label = self._to_example(
624+
prog_input, boundary_input, label_tensor = self._to_example(
611625
OceanData.from_data_source(
612626
input_,
613627
self.wet_prognostic[0],
@@ -622,43 +636,27 @@ def to_train_data(
622636
label, self.wet_prognostic[-1], self.prognostic_srcs[-1]
623637
).to(device=device, non_blocking=True),
624638
)
625-
train_data.append(input_, label)
639+
train_data.append(prog_input, boundary_input, label_tensor)
626640
train_data.load_stats = raw_train_data.load_stats
627641
return train_data
628642

629643
def _to_example(
630644
self, input_: OceanData, boundary: OceanData, label: OceanData
631-
) -> tuple[Input, Prognostic]:
645+
) -> Example:
632646
# Input/boundary only include current steps; label only includes forecasted steps.
633-
total_input = self._prep_tensor_steps(input_, boundary)
647+
prog_input = self._prep_tensor_steps(input_)
648+
boundary_input = self._prep_tensor_steps(boundary)
634649
label_tensor = self._prep_tensor_steps(label)
635-
return total_input, label_tensor
650+
return prog_input, boundary_input, label_tensor
636651

637-
def _prep_tensor_steps(
638-
self,
639-
prognostic: OceanData,
640-
boundary: OceanData | None = None,
641-
) -> Input:
642-
"""Prepare tensor steps by normalizing, masking and flattening dimensions."""
643-
prognostic_steps = prognostic.normalize_and_mask(
652+
def _prep_tensor_steps(self, ocean_data: OceanData) -> Input:
653+
"""Normalize, mask, and flatten (time, variable) dims into a channel dim."""
654+
steps = ocean_data.normalize_and_mask(
644655
self.normalize_before_mask, self.masked_fill_value
645656
)
646-
647-
# Flatten time and variable dimensions
648-
def flatten_dims(tensor: torch.Tensor) -> torch.Tensor:
649-
return rearrange(
650-
tensor, "batch time variable lat lon -> batch (time variable) lat lon"
651-
)
652-
653-
prognostic_steps = flatten_dims(prognostic_steps)
654-
if boundary is not None:
655-
boundary_steps = boundary.normalize_and_mask(
656-
self.normalize_before_mask, self.masked_fill_value
657-
)
658-
boundary_steps = flatten_dims(boundary_steps)
659-
return torch.cat((prognostic_steps, boundary_steps), dim=1)
660-
661-
return prognostic_steps
657+
return rearrange(
658+
steps, "batch time variable lat lon -> batch (time variable) lat lon"
659+
)
662660

663661
def _get_x_index(self, idx: int, step: int) -> xr.DataArray:
664662
assert isinstance(idx, int)

src/ocean_emulators/eval.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
construct_metadata,
1919
)
2020
from ocean_emulators.datasets import InferenceDataset
21-
from ocean_emulators.stepper import Stepper
21+
from ocean_emulators.stepper import inference
2222
from ocean_emulators.utils.data import (
2323
Normalize,
2424
get_inference_steps,
@@ -76,8 +76,10 @@ def __init__(self, cfg: EvalConfig) -> None:
7676
self.N_bound = len(self.boundary_var_names)
7777
self.N_prog = len(self.prognostic_var_names)
7878

79-
self.num_in = int((cfg.data.hist + 1) * (self.N_prog + self.N_bound))
80-
self.num_out = int((cfg.data.hist + 1) * self.N_prog)
79+
self.num_prog_in = int((cfg.data.hist + 1) * self.N_prog)
80+
self.num_boundary_in = int((cfg.data.hist + 1) * self.N_bound)
81+
self.num_in = self.num_prog_in + self.num_boundary_in
82+
self.num_out = self.num_prog_in
8183

8284
self.tensor_map = TensorMap.init_instance(
8385
cfg.experiment.prognostic_vars_key, cfg.experiment.boundary_vars_key
@@ -110,7 +112,8 @@ def __init__(self, cfg: EvalConfig) -> None:
110112

111113
# Model
112114
self.model = cfg.model.build(
113-
in_channels=self.num_in,
115+
prog_channels=self.num_prog_in,
116+
boundary_channels=self.num_boundary_in,
114117
out_channels=self.num_out,
115118
hist=cfg.data.hist,
116119
static_data_for_corrector=self.static_data,
@@ -207,7 +210,7 @@ def standalone_inference(self):
207210
self.prognostic_var_names,
208211
)
209212

210-
Stepper.inference(
213+
inference(
211214
model=self.model,
212215
dataset=self.inference_dataset,
213216
inf_aggregator=inf_aggregator,

0 commit comments

Comments
 (0)