Serialize StepperState across segmented-inference restarts#1341
Serialize StepperState across segmented-inference restarts#1341mcgibbon wants to merge 18 commits into
Conversation
Add a context-managed active torch.Generator that randn/randn_like draw from when set, mirroring the existing USE_CPU_RANDN global. Draws are made on the CPU using the generator and then moved to the requested device, so they are reproducible given the seed and independent of the compute device and of unrelated global-RNG consumption. A None generator is a no-op, preserving the default global-RNG behavior.
Add a RandomState (wrapping a CPU torch.Generator) and carry it on StepperState alongside the corrector state. Stepper.step activates the generator via fme.core.rand.use_generator around the network call - so stochastic modules like NoiseConditionedSFNO draw their noise from it - and re-attaches the (now-advanced, same-object) RandomState to the returned StepperState, surviving steps that rebuild StepperState to seed corrector state. Because the same generator object advances in place and rides PrognosticState.stepper_state from one step and predict call to the next, a seeded rollout's noise sequence is deterministic and independent of how it is chunked into forward_steps_in_memory windows. The device/ensemble helpers are no-ops that preserve the single advancing generator. PrognosticState.with_random_state attaches a seed to an initial condition. Integration tests (minimal NoiseConditionedSFNO, non-zero noise dim) cover forward_steps_in_memory independence and same-seed/different-seed rollouts.
Add an optional seed field to InferenceConfig and InferenceEvaluatorConfig. When set, attach a RandomState.from_seed(seed) to the rollout's initial condition so stochastic inference is reproducible. Backwards compatible (defaults to None / current behavior). In the evaluator this only affects the stepper rollout path, not the prediction_loader comparison path which does not run the stepper.
The corrector branch of step_with_adjustments rebuilt StepperState from scratch (StepperState(corrector_state=...)), dropping every other field. Stepper.step compensated by re-attaching random_state after the inner step returned. Preserve the incoming state via dataclasses.replace so the inner step keeps StepperState coherent across calls (and future-proofs any other field), and remove the now-unnecessary caller-side fix-up. Addresses review feedback on #1330.
The comment explained the absence of the removed random_state fix-up rather than the current code; the invariant it described lives on the inner step_with_adjustments docstring.
# Conflicts: # fme/ace/stepper/single_module.py # fme/ace/stepper/test_single_module.py # fme/core/step/single_module.py
In segmented inference the terminal StepperState (corrector_state + random_state) was dropped at each restart: the writer serialized only the prognostic tensors, so the noise generator restarted at every segment boundary and the corrector re-captured its pinned global_dry_air_mass from the segment boundary rather than the true IC. - fme.core.random_state.RandomState, fme.core.corrector.state.CorrectorState, fme.core.stepper_state.StepperState: add symmetric to_state_dict / from_state_dict. RandomState serializes the advanced generator state (get_state), CorrectorState omits unset fields, StepperState namespaces sub-state keys with a present-marker so None sub-states restore as None. - fme.core.generics.writer.WriterABC and the concrete DataWriter / PairedDataWriter / NullDataWriter / CoupledPairedDataWriter: add write_stepper_state, a no-op when there is no StepperState; run_inference writes restart_stepper_state.pt beside restart.nc. - fme.ace.inference.InitialConditionConfig: add stepper_state_path (loaded via fsspec, raising on a set-but-missing path); run_segmented_inference points each segment at the previous segment's sidecar when present. - fme.ace.data_loading.batch_data.PrognosticState.with_stepper_state, with with_random_state now a thin wrapper. - config.seed precedence (inference + evaluator): apply the seed only when the IC carries no restored random_state, logging when a restored state supersedes it, so segment 0 seeds and later segments continue the restored generator.
|
Claude: Design note on |
The restart sidecar is serialized on CPU. When attaching the restored StepperState to the initial condition, move it to the compute device so a restored corrector_state (e.g. the pinned global_dry_air_mass) lands on the same device as the rollout tensors. Without this, a GPU segmented run with a dry-air-conserving corrector would hit a CPU/GPU device mismatch when the corrector subtracts the restored target from the on-device prediction. RandomState.to_device is a no-op, so the generator correctly stays on CPU.
|
Claude (pre-review agent): Applied fix pushed to this branch (d2ae7ff). [High] Restored stepper state was left on CPU → GPU device mismatch. Fix: |
| logging.info( | ||
| "Ignoring config seed because a random state was restored from the " | ||
| "restart stepper state; the restored generator continues instead." | ||
| ) |
There was a problem hiding this comment.
This ignore is a little scary, but I couldn't think of a good way to avoid it.
There was a problem hiding this comment.
Claude: The skip is intentional and is exactly what makes a resumed rollout continue the same generator — dropping it would reseed at each segment boundary and break the bitwise-identical-to-single-run property this PR establishes. It isn't silent: when a restored random state supersedes config.seed we log an INFO line. I considered raising when both a seed and a restored state are present, but in segmented runs config.seed is legitimately set once and carried into every segment via the deep-copied config, so raising would break the normal flow (segment 0 seeds, segments 1+ inherit the same config with the seed still set). The INFO log is the mitigation. Happy to elevate it to a warning if you'd prefer it more visible — let me know.
There was a problem hiding this comment.
Noting this with a log statement seems reasonable to me. If we find a need for it down the road we could introduce a configuration flag to ignore the random state in the restart file, but I think this current default behavior is more often than not what people would want.
This has a flavor to it like the behavior of GFDL models around setting the current model time at initialization. If reading from restart files, it will be set from those unless a flag is set to override it from the namelist.
|
Claude (pre-review agent): Verdict — ready for review (after the one fix I applied above). Reviewed the delta on top of #1330 only. The design is clean and the round-trip is correct. Verified
Non-blocking suggestions (for the author, optional)
|
| # Point the next segment at this segment's stepper-state sidecar so the | ||
| # random/corrector state continues across the restart. The sidecar is | ||
| # written only when there is state to save (a seeded and/or corrector | ||
| # rollout), so a deterministic rollout - or a pre-feature restart dir - | ||
| # has none, and the next segment starts fresh (stepper_state_path=None). |
There was a problem hiding this comment.
This is quite a bit of a comment, and could easily go out of date. Is there any way to make this information obvious with code structure instead? Or is this comment even necessary?
Another question, why is it that restart_path would always be passed but sidecar_path is only sometimes passed? And would it be clearer to call this stepper_state_path instead of "sidecar" (which is jargon our team doesn't use)?
There was a problem hiding this comment.
Claude: Done in 7392e9d. (1) Purged "sidecar" everywhere — comments, docstrings, the evaluator's "restart sidecar", the local var (sidecar_path → stepper_state_path), and the test names (test_stepper_state_sidecar_* → test_stepper_state_file_*); prose now says "stepper state file" / "restart stepper state" (the public field was already stepper_state_path). (2) Cut the multi-line comment in run_segmented_inference to a single intent line. (3) On the asymmetry (restart_path always passed, stepper_state_path only sometimes): kept the conditional deliberately. restart.nc is the IC and always exists, but the stepper state file exists only when a segment had state to persist (seeded and/or corrector) — a deterministic segment or a pre-feature restart dir has none. A set-but-missing stepper_state_path is a genuine user error and trips the FileNotFoundError guard, so the driver only sets it when the file is actually present.
There was a problem hiding this comment.
Claude: This asymmetry is now moot — resolved by the redesign. The .pt sidecar is gone; the full PrognosticState (including StepperState) is embedded directly in restart.nc, so the next segment's IC is just InitialConditionConfig(path=restart_path) with no stepper_state_path and no conditional. See the design-change comment on the PR.
There was a problem hiding this comment.
Can/should we assert the random_state is the random_state we passed in?
There was a problem hiding this comment.
Claude: Added in 7392e9d. The test now captures the RandomState instance placed on the IC and asserts terminal.random_state is random_state (identity, not just presence) — the generator advances in place and the device/ensemble helpers return self, so the exact instance is threaded through the step that rebuilds StepperState.
…pper-state-restart # Conflicts: # fme/ace/data_loading/batch_data.py # fme/ace/inference/evaluator.py # fme/ace/inference/inference.py # fme/core/random_state.py # fme/core/test_random_state.py
…ate identity - Rename 'sidecar' to 'stepper state file' / 'restart stepper state' in all comments, docstrings, and test names (public field stepper_state_path and local var renamed from sidecar_path). - Shorten the run_segmented_inference comment to a single intent line. - test_predict_threads_random_state_alongside_corrector_state: assert the terminal random_state IS the instance passed in (identity), not just present.
Restart.nc was a lossy projection of PrognosticState (only data+time); StepperState was just the first dropped field. Embed the whole round-trippable state (stepper_state, labels, data_mask) into the same restart netCDF under reserved _fme_state__ variables + a schema-version marker attr, so all of it persists in one inspectable file and the restart_path/stepper_state_path asymmetry dissolves. - fme/ace/inference/data_writer/restart.py (new): encode/decode of the reserved extras. Reuses StepperState.to_state_dict/from_state_dict as the tensor intermediate; the generator state (uint8 ByteTensor) round-trips through a netCDF ubyte variable and set_state reproduces the stream. Only present fields are written, so a deterministic run's restart is byte-identical to today's data+time-only file. n_ensemble/horizontal_dims are intentionally derived on load, not serialized. - Writer: drop write_stepper_state from WriterABC and all impls and the restart_stepper_state.pt write from run_inference; _write now embeds the extras into restart.nc (and initial_condition.nc, harmlessly). - Reader: remove stepper_state_path/get_stepper_state; get_initial_condition auto-detects the schema marker, reconstructs labels/data_mask/stepper_state (embedded labels win over config; a plain netCDF reads exactly as before), and attaches via with_stepper_state. Subselection subsets the per-sample corrector state in step with prognostics and leaves the generator untouched. - Segmented driver: next segment is just InitialConditionConfig(path=restart) - the full state rides restart.nc; asymmetry gone. - config.seed precedence unchanged.
|
Claude: Design change — replaced the
The |
mcgibbon
left a comment
There was a problem hiding this comment.
Halting a full review, these comments have wide-reaching implications.
| def to_device(self) -> "PrognosticState": | ||
| return PrognosticState(self._data.to_device()) | ||
|
|
||
| def with_stepper_state(self, stepper_state: StepperState) -> "PrognosticState": |
There was a problem hiding this comment.
Instead of having a BatchData.with_stepper_state, can we have a method that serializes/deserializes BatchData to/from an xarray.Dataset (which we could then store to disk when we save restarts)? This would make the functionality easier to unit test.
There was a problem hiding this comment.
Claude: Done in b95e7e1. Added BatchData.to_xarray_dataset() / from_xarray_dataset() as the full BatchData↔Dataset round-trip: to_xarray_dataset produces the complete restart dataset (prognostic vars under plain names + time, single-timestep squeeze preserved, plus the reserved _fme_state__ extras when present with the _fme_schema_version marker), and from_xarray_dataset is its strict inverse (recovers data, time, horizontal_dims, and embedded stepper_state/labels/data_mask with exact dtypes). The prognostic-data + time serialization that used to live in the writer's _write is folded in. Removed PrognosticState.with_stepper_state and inlined its one use in with_random_state (a dataclasses.replace of the stepper_state) — the reader now reconstructs the whole BatchData (incl. stepper_state) via from_xarray_dataset, so the attach helper is unneeded. Added pure BatchData↔Dataset round-trip unit tests (no writer/inference machinery) parametrized over every field combination.
|
|
||
|
|
||
| @dataclasses.dataclass | ||
| class RestartExtras: |
There was a problem hiding this comment.
This should live with BatchData, not with the inference data writers.
There was a problem hiding this comment.
Claude: Done — restart.py is deleted and all of its logic moved onto BatchData: the _fme_state__ prefix, the schema-version marker, _fme_dtype dtype preservation, the generator ubyte encoding, and the corrector/labels/data_mask encoding now live in to_xarray_dataset/from_xarray_dataset and the private _encode_reserved_state/_decode_reserved_state helpers (the old RestartExtras/encode_restart_extras/decode_restart_extras/has_restart_extras are gone). The writer's _write is now ds = data.to_xarray_dataset() + metadata/coords/dataset_metadata; the reader gates on BatchData.dataset_has_embedded_state and calls from_xarray_dataset. Contract as implemented: from_xarray_dataset is a strict inverse of to_xarray_dataset only — it is not asked to interpret arbitrary external IC datasets or legacy plain restart files (those keep the lenient get_initial_condition path). Generator uint8 ↔ netCDF ubyte round-trip and set_state stream reproduction are verified (through-netCDF test in test_batch_data.py).
Restructure (per review) without changing behavior: the netCDF-embedding logic that lived in data_writer/restart.py now lives on BatchData as the symmetric to_xarray_dataset/from_xarray_dataset round-trip, which also folds in the prognostic-data + time serialization previously in the writer's _write. restart.py is deleted. - BatchData.to_xarray_dataset(): the complete dataset - prognostic vars under plain names + time (single-timestep squeeze preserved) + reserved _fme_state__ extras (stepper_state, labels, data_mask) with a schema-version marker, written only when present so a no-extras batch stays byte-identical to today's data+time file. - BatchData.from_xarray_dataset(): strict inverse; recovers data, time, horizontal_dims, and embedded extras with exact dtypes (generator uint8 via netCDF ubyte; bool via _fme_dtype). Guaranteed only on to_xarray_dataset output, not arbitrary external ICs. - BatchData.dataset_has_embedded_state(ds): schema-marker predicate for the reader. - Writer _write: ds = data.to_xarray_dataset(), then add metadata attrs, coords, dataset_metadata. - Reader get_initial_condition: marker present -> from_xarray_dataset (+ move restored stepper_state to device, keeping the GPU-corrector fix); no marker -> existing lenient plain path unchanged (external ICs and legacy restart). - Remove PrognosticState.with_stepper_state; inline its one use in with_random_state. - Tests: new pure BatchData<->Dataset round-trip units in test_batch_data.py (every field combination, in-memory + through-netCDF + multi-timestep); integration tests updated to the new API; slow segmented acceptance test still passes.
|
Claude: Independent pre-review (Opus, worktree-isolated, no authoring context). I reviewed the full current diff vs Audit passes
Tests — all green: Non-blocking notes (no fix applied — both are defensiveness judgment calls, not bugs; flagging for the reviewer):
Does it accomplish its purpose / is it easy to review? Yes. The serialization is cleanly owned by |
| Returns: | ||
| The initial condition and the time coordinate. | ||
| """ | ||
| if BatchData.dataset_has_embedded_state(ds): |
There was a problem hiding this comment.
I don't understand why this conditional is here, perhaps it would be more obvious if the indented code and the code below were each contained in helper functions?
There was a problem hiding this comment.
Claude: Done. get_initial_condition is now a thin dispatcher:
if BatchData.dataset_has_embedded_state(ds):
return _initial_condition_from_state(ds, prognostic_names, labels, n_ensemble)
return _initial_condition_from_variables(ds, prognostic_names, labels, n_ensemble)
_initial_condition_from_variables is the unchanged lenient plain-tensor path (external ICs and legacy restarts: builds prognostic tensors from the named variables, sets labels from config, broadcasts the ensemble). _initial_condition_from_state is the embedded-full-state path (rebuild via from_xarray_dataset, validate, wrap, move stepper_state to device — see the comment on line 156). The docstring now describes the validate-not-rederive split.
| # The full state (stepper_state, labels, data_mask) rides restart.nc, so | ||
| # the next segment just points at it and resumes exactly. |
There was a problem hiding this comment.
Unnecessary comment.
There was a problem hiding this comment.
Claude: Deleted the comment above config_copy.initial_condition = InitialConditionConfig(...) in run_segmented_inference.
| if batch_data.labels is None and labels is not None: | ||
| n_samples = batch_data.time.shape[0] | ||
| batch_data = dataclasses.replace( | ||
| batch_data, | ||
| labels=BatchLabels(torch.ones(n_samples, len(labels)), names=labels), | ||
| ) | ||
| batch_data = batch_data.broadcast_ensemble(n_ensemble=n_ensemble) | ||
| initial_state = batch_data.get_start(prognostic_names, n_ic_timesteps=1) | ||
| stepper_state = initial_state.as_batch_data().stepper_state | ||
| if stepper_state is not None: | ||
| initial_state = PrognosticState( | ||
| dataclasses.replace( | ||
| initial_state.as_batch_data(), | ||
| stepper_state=stepper_state.to_device(), | ||
| ) | ||
| ) |
There was a problem hiding this comment.
When we're loading BatchData from disk, we shouldn't need to set labels, broadcast the ensemble, get the correct prognostic names, etc. as this was already done before we saved it. You just need to ensure that if values are passed for these, they get validated against the loaded value. For example, we should error out if we attempted to load with labels that doesn't match batch_data.labels. This should be contained in a validation helper, perhaps a BatchData.validate_initial_condition that takes in the necessary arguments from this scope.
There was a problem hiding this comment.
Claude: Done — the embedded path now validates rather than re-derives. Added BatchData.validate_initial_condition(self, prognostic_names, labels, n_ensemble) which raises on inconsistency: (a) any name in prognostic_names absent from self.data (error lists the missing names and the present ones); (b) labels given but not matching — self.labels None while config provides labels, or self.labels.names != list(labels); (c) n_ensemble != self.n_ensemble (for a loaded full-state IC that's != 1), with a message that a full-state restart can't be re-broadcast because its sample dim already carries the ensemble.
_initial_condition_from_state is now: batch_data = BatchData.from_xarray_dataset(ds) → batch_data.validate_initial_condition(prognostic_names, labels, n_ensemble) → get_start(prognostic_names, n_ic_timesteps=1) (only the wrap-into-IC step; no broadcast, no label-setting) → .to_device() the restored stepper_state (RandomState.to_device is a no-op; GPU-corrector fix kept). Unit tests added for validate_initial_condition: passes when consistent; raises on a labels mismatch, a labels-provided-but-none-saved case, a missing prognostic name, and an n_ensemble mismatch.
…pper-state-restart
…dition; explicit per-sample encoding Addresses review feedback on the BatchData-owned restart serialization: - get_initial_condition is now a thin dispatcher over two helpers: _initial_condition_from_state (embedded full-state restart) and _initial_condition_from_variables (the lenient plain path for external ICs and legacy restarts, unchanged). - BatchData.validate_initial_condition(prognostic_names, labels, n_ensemble): a loaded full-state IC already had names/labels/ensemble applied before it was saved, so the embedded path validates config values for consistency (missing prognostic name, label mismatch incl. loaded-None, n_ensemble mismatch -> a full-state restart cannot be re-broadcast) and raises rather than re-setting labels / re-broadcasting / re-selecting names. It still wraps into the IC via get_start and moves the restored stepper_state to device. - Per-sample-ness of reserved state variables is now explicit: each sub-state declares per_sample_state_keys() (corrector: global_dry_air_mass; random_state: none), and _reserved_var_dims marks the sample dim from that declaration instead of inferring it from a length matching n_samples. Adds a regression test where a non-per-sample var's length equals the sample count. - Writer metadata loop explicitly skips time and reserved _fme_state__ vars. - Drop an unnecessary comment in run_segmented_inference. - Tests: validate_initial_condition (consistent / missing name / label mismatch / labels-vs-none / n_ensemble); the per-sample regression; updated the full-state round-trip test to pass matching (validated) labels.
|
Claude: Independent pre-review of the latest round (c29d46f), focused on the four deltas. Verdict: ready for review — no findings, no fixes applied. Ran on CPU:
Round-trip inverse, generator uint8/set_state continuation across segments (the slow acceptance test), and plain/legacy → Minor, non-blocking, pre-existing and out of scope: |
| """ | ||
| if seed is None: | ||
| return | ||
| stepper_state = data.initial_condition.as_batch_data().stepper_state |
There was a problem hiding this comment.
Instead of putting the low-level code here and digging several levels deep, please call data.apply_config_seed, a new method you will write, which will in turn call self._initial_condition = self._initial_condition.apply_config_seed. You can retain the same behavior you have now, logging if it's already been seeded.
There was a problem hiding this comment.
Claude: Done in e8427ed. run_evaluator_from_config (and run_inference_from_config) now call data.apply_config_seed(config.seed). InferenceGriddedData.apply_config_seed does self._initial_condition = self._initial_condition.apply_config_seed(seed), and the seed-or-defer logic (including the info log when a restored random state supersedes the config seed) lives on PrognosticState.apply_config_seed. The module-level helper in evaluator.py — and its reach into data._initial_condition from outside — is gone. Unit tests added for the PrognosticState method (seeds when unseeded, returns self on None, defers to a restored state); the segmented acceptance test still covers the whole chain.
The restored stepper_state is moved to the compute device on restart, so the corrector's global_dry_air_mass lands on cuda on GPU runners while the CPU-built expected tensors did not. Move expected tensors to fme.get_device() before comparison. No-op on CPU.
| if BatchData.dataset_has_embedded_state(ds): | ||
| return _initial_condition_from_state(ds, prognostic_names, labels, n_ensemble) | ||
| return _initial_condition_from_variables(ds, prognostic_names, labels, n_ensemble) |
There was a problem hiding this comment.
This feels wrong somehow, I can't put my finger on it. Like it feels like there should be a method on a class somewhere, maybe? Or we should put the last three arguments into a container class?
There was a problem hiding this comment.
Claude: Done in e8427ed — went with the container class. InitialConditionRequirements (fme/ace/requirements.py, beside PrognosticStateDataRequirements) bundles prognostic_names/labels/n_ensemble — "what the run requires of its initial condition" — and flows through get_initial_condition, both build helpers, and BatchData.validate_initial_condition(requirements), so the validate call is now the container handed straight down. Callers (standalone inference, coupled) construct it at the top.
Chasing the "feels wrong" further turned up a real wart in _initial_condition_from_state: its explicit stepper_state-to-device re-wrap was (a) redundant — InferenceGriddedData.__init__ already calls initial_condition.to_device(), and BatchData.to_device moves stepper_state (corrector to device, generator a no-op on CPU) — and (b) asymmetric, since the plain-variables path returns everything on CPU. Both paths now return CPU state and the device move happens in one place; the two GPU tests that asserted the early move now compare on CPU.
- PrognosticState.apply_config_seed + InferenceGriddedData.apply_config_seed replace the module-level helper in evaluator.py that reached into data._initial_condition from outside. - InitialConditionRequirements (fme/ace/requirements.py) bundles prognostic_names/labels/n_ensemble through get_initial_condition, both build paths, and BatchData.validate_initial_condition. - Drop the explicit stepper_state device move in _initial_condition_from_state: both IC paths now return CPU state and InferenceGriddedData.__init__ already moves the whole BatchData (including stepper_state) to the compute device; tests compare on CPU accordingly.
|
Claude: Pre-review of round-4 commit e8427ed (review-response), read in the context of the full PR diff against What I verified:
|
spencerkclark
left a comment
There was a problem hiding this comment.
Thanks—it's going to be super nice to have this for formal and informal segmented runs. It seems like you have covered all the bases and it's great that the full BatchData state is now possible to roundtrip through a single netCDF.
I had a couple questions regarding whether it would be possible to simplify the schema some by adopting existing conventions rather than defining our own, but otherwise just minor nits.
| time_da = ds[_TIME_DIM] | ||
| squeezed = list(time_da.dims) == [_SAMPLE_DIM] | ||
| if squeezed: | ||
| time = xr.DataArray(time_da.values[:, None], dims=[_SAMPLE_DIM, _TIME_DIM]) | ||
| else: | ||
| time = xr.DataArray(time_da.values, dims=[_SAMPLE_DIM, _TIME_DIM]) |
There was a problem hiding this comment.
nit: this is slightly tidier and more closely mirrors the later tensor logic:
| time_da = ds[_TIME_DIM] | |
| squeezed = list(time_da.dims) == [_SAMPLE_DIM] | |
| if squeezed: | |
| time = xr.DataArray(time_da.values[:, None], dims=[_SAMPLE_DIM, _TIME_DIM]) | |
| else: | |
| time = xr.DataArray(time_da.values, dims=[_SAMPLE_DIM, _TIME_DIM]) | |
| time = ds[_TIME_DIM] | |
| squeezed = list(time.dims) == [_SAMPLE_DIM] | |
| if squeezed: | |
| time = time.expand_dims(dim=_TIME_DIM, axis=1) |
| data_arrays[_LABELS_VALUES_VAR] = xr.DataArray( | ||
| array, | ||
| dims=[_SAMPLE_DIM, _LABEL_INDEX_DIM], | ||
| attrs={_DTYPE_ATTR: dtype_name}, | ||
| ) | ||
| attrs[_LABELS_NAMES_ATTR] = json.dumps(list(self.labels.names)) |
There was a problem hiding this comment.
Question: are the self.labels.names interpretable as a coordinate along the _LABEL_INDEX_DIM? In other words could they equivalently be encoded as a dimension coordinate rather than in json as a Dataset-level attribute? It could make things slightly more intuitive to human readers of the restart files.
| # netCDF4 has no native bool; bool tensors are stored as uint8 and cast back to | ||
| # their recorded dtype on read. | ||
| _DTYPE_BY_NAME: dict[str, torch.dtype] = { | ||
| str(dtype): dtype | ||
| for dtype in ( | ||
| torch.bool, | ||
| torch.uint8, | ||
| torch.int8, | ||
| torch.int16, | ||
| torch.int32, | ||
| torch.int64, | ||
| torch.float16, | ||
| torch.float32, | ||
| torch.float64, | ||
| ) | ||
| } |
There was a problem hiding this comment.
Question: xarray already has a mechanism for automatically roundtripping bool dtype variables to and from netCDF. Could leveraging that remove the need to define our own dtype encoding mechanism (modulo conversion between NumPy arrays and torch tensors)?
| n_ensemble: Number of ensemble members per initial state | ||
| ds: Dataset containing initial condition data. Must include the required | ||
| prognostic names as variables, and they must each have shape | ||
| (n_samples, n_lat, n_lon). Dataset must also include a 'time' |
There was a problem hiding this comment.
nit: not new, but this works for HEALPix data too, correct?
| if self.time.sizes[_TIME_DIM] == 1: | ||
| time_dim = self.dims.index(_TIME_DIM) | ||
| dims_to_write = self.dims[:time_dim] + self.dims[time_dim + 1 :] | ||
|
|
||
| def present(x: torch.Tensor) -> torch.Tensor: | ||
| return x.squeeze(dim=time_dim) | ||
|
|
||
| time_array = self.time.isel(time=0) | ||
| else: | ||
| dims_to_write = self.dims | ||
|
|
||
| def present(x: torch.Tensor) -> torch.Tensor: | ||
| return x | ||
|
|
||
| time_array = self.time | ||
|
|
||
| data_arrays: dict[str, xr.DataArray] = {} | ||
| for name, tensor in self.data.items(): | ||
| data_arrays[name] = xr.DataArray( | ||
| present(tensor).detach().cpu().numpy(), dims=dims_to_write | ||
| ) | ||
| data_arrays[_TIME_DIM] = time_array | ||
|
|
||
| extra_arrays, attrs = self._encode_reserved_state() | ||
| data_arrays.update(extra_arrays) | ||
| ds = xr.Dataset(data_arrays) | ||
| ds.attrs.update(attrs) | ||
| return ds |
There was a problem hiding this comment.
nit: I realize much of this is inherited from earlier code, but is there a reason we cannot simplify this to the following? It should be possible to handle the squeeze all at once at the Dataset level.
| if self.time.sizes[_TIME_DIM] == 1: | |
| time_dim = self.dims.index(_TIME_DIM) | |
| dims_to_write = self.dims[:time_dim] + self.dims[time_dim + 1 :] | |
| def present(x: torch.Tensor) -> torch.Tensor: | |
| return x.squeeze(dim=time_dim) | |
| time_array = self.time.isel(time=0) | |
| else: | |
| dims_to_write = self.dims | |
| def present(x: torch.Tensor) -> torch.Tensor: | |
| return x | |
| time_array = self.time | |
| data_arrays: dict[str, xr.DataArray] = {} | |
| for name, tensor in self.data.items(): | |
| data_arrays[name] = xr.DataArray( | |
| present(tensor).detach().cpu().numpy(), dims=dims_to_write | |
| ) | |
| data_arrays[_TIME_DIM] = time_array | |
| extra_arrays, attrs = self._encode_reserved_state() | |
| data_arrays.update(extra_arrays) | |
| ds = xr.Dataset(data_arrays) | |
| ds.attrs.update(attrs) | |
| return ds | |
| data_arrays: dict[str, xr.DataArray] = {} | |
| for name, tensor in self.data.items(): | |
| data_arrays[name] = xr.DataArray( | |
| tensor.detach().cpu().numpy(), dims=self.dims | |
| ) | |
| data_arrays[_TIME_DIM] = self.time | |
| extra_arrays, attrs = self._encode_reserved_state() | |
| data_arrays.update(extra_arrays) | |
| ds = xr.Dataset(data_arrays) | |
| ds.attrs.update(attrs) | |
| if ds.sizes[_TIME_DIM] == 1: | |
| ds = ds.squeeze(_TIME_DIM) | |
| return ds |
| logging.info( | ||
| "Ignoring config seed because a random state was restored from the " | ||
| "restart stepper state; the restored generator continues instead." | ||
| ) |
There was a problem hiding this comment.
Noting this with a log statement seems reasonable to me. If we find a need for it down the road we could introduce a configuration flag to ignore the random state in the restart file, but I think this current default behavior is more often than not what people would want.
This has a flavor to it like the behavior of GFDL models around setting the current model time at initialization. If reading from restart files, it will be set from those unless a flag is set to override it from the namelist.
Segmented inference threads the full
PrognosticStatethrough the rollout in memory, butrestart.nconly ever persisted a lossy projection of it — the prognosticdataandtime. Everything else on the terminalBatchData(theStepperState= corrector + random state, pluslabelsanddata_mask) was silently dropped, so on resume the noise generator restarted at each segment boundary (the cross-restart reproducibility Spencer raised on #1330) and the corrector re-captured its pinnedglobal_dry_air_massfrom the segment boundary instead of the true IC.This PR makes
BatchDataown its own serialization, sorestart.ncbecomes a faithful, self-contained, still-inspectable netCDF of the whole state.Changes:
BatchData.to_xarray_dataset()/from_xarray_dataset()(fme/ace/data_loading/batch_data.py): the fullBatchData↔Datasetround-trip.to_xarray_datasetwrites prognostic variables under their plain names +time(single-timestep time dimension squeezed, matching the prior restart shape), plus the round-trippable extras (stepper_state,labels,data_mask) under reserved_fme_state__variables with a_fme_schema_versionmarker attribute — but only when present, so a batch carrying none serializes to a plain data+time dataset byte-identical to what was written before this feature.from_xarray_datasetis the strict inverse (recovers data, time,horizontal_dims, and the extras with exact dtypes).StepperState.to_state_dict/from_state_dictremain the tensor intermediate (the stepper stays opaque); the generator state (uint8ByteTensor) round-trips through a netCDF ubyte variable; the corrector'sglobal_dry_air_masscarries the sharedsampledim sostart_indicessubselection subsets it in step with the prognostics.n_ensemble/horizontal_dimsare derived on load, not serialized (avoids the double-broadcast bug)._writeis nowds = data.to_xarray_dataset()plus the writer's presentation (per-variable metadata attrs, coords, dataset metadata).write_stepper_stateand the.ptwrite are gone.get_initial_condition): takes anInitialConditionRequirements(a new container infme/ace/requirements.pybundlingprognostic_names/labels/n_ensemble) and dispatches. A dataset with the schema marker (BatchData.dataset_has_embedded_state) is rebuilt viafrom_xarray_datasetand validated against the requirements (BatchData.validate_initial_condition) rather than re-derived. A plain netCDF without the marker (external ICs and legacy restart files) takes the existing lenient path unchanged:stepper_state=None, labels from config. Both paths return CPU state;InferenceGriddedData.__init__moves it to the compute device (BatchData.to_devicemoves thestepper_state; the generator stays CPU — keeps the GPU-corrector fix).PrognosticState.with_stepper_stateremoved (the reader reconstructs the wholeBatchData);with_random_statekeeps the seed path.InitialConditionConfig(path=restart.nc)— the full state rides the restart.config.seedprecedence (inference.py+evaluator.py, viadata.apply_config_seed, which delegates toPrognosticState.apply_config_seed): applied only when the IC carries no restoredrandom_state; a restored state that supersedes it logs an info line.Scope: inference segmented path only; training-restart is untouched (matches #1330's scope).
Stacked originally on #1330 (now merged to
main).