Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 51 additions & 20 deletions src/underworld3/checkpoint/disk_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import json
import os
import warnings
from contextlib import contextmanager
from typing import Any, Optional

import numpy as np
Expand Down Expand Up @@ -247,8 +248,8 @@ def read_snapshot_metadata(path: str) -> dict:
#
# /path/to/run.snap.h5 wrapper (metadata, h5py-readable)
# /path/to/run.snap.bulk/ companion directory (one per snapshot)
# {mesh_safe}.mesh.00000.h5 mesh DM dump (PETSc HDF5)
# {mesh_safe}.{var_clean}.00000.h5 per-variable section + vec (PETSc HDF5)
# mesh_0000.mesh.00000.h5 mesh DM dump (PETSc HDF5)
# mesh_0000.{var_clean}.00000.h5 per-variable section + vec (PETSc HDF5)
# ... one set per (mesh, var) ...
#
# The bulk-dir path is derived from the wrapper path by convention, so a
Expand All @@ -262,6 +263,23 @@ def _bulk_dir_for(wrapper_path: str) -> str:
return base + ".bulk"


@contextmanager
def _short_io_path(path: str):
"""Expose ``path`` to native I/O as a basename from its parent directory.

Some parallel PETSc/HDF5 stacks fail on valid absolute paths well below
``PATH_MAX``. Snapshot artifacts retain their normal locations, while the
native reader or writer receives only the final path component.
"""
absolute_path = os.path.abspath(path)
previous_directory = os.getcwd()
os.chdir(os.path.dirname(absolute_path))
try:
yield os.path.basename(absolute_path)
finally:
os.chdir(previous_directory)


def _sanitise(name: str) -> str:
"""Sanitise a mesh / variable name for use as a filename component.

Expand Down Expand Up @@ -306,8 +324,13 @@ def write_snapshot(model, path: str) -> str:
# bulk directory. write_checkpoint is collective (PETSc HDF5
# viewer), so all ranks must participate.
mesh_records: list[dict] = []
for mesh in list(model._meshes.values()):
mesh_safe = _sanitise(mesh.name)
for mesh_index, mesh in enumerate(list(model._meshes.values())):
# Loaded meshes commonly use their complete source path as ``name``.
# Embedding that path in every PETSc-HDF5 bulk filename can exceed
# practical MPI-I/O pathname limits even when the wrapper path itself
# is valid. The wrapper preserves the original name for exact restore
# matching, so bulk files only need a compact deterministic identifier.
mesh_safe = f"mesh_{mesh_index:04d}"
mesh_vars = list(mesh.vars.values())
# Filter to allocated variables — same skip rule as the in-memory
# path: lazy-allocated vars with _gvec == None have no data.
Expand All @@ -319,14 +342,15 @@ def write_snapshot(model, path: str) -> str:
# (consumed by the reload path below). Suppress the FutureWarning for
# this internal call rather than spam every snapshot. (Migrating the
# snapshot to write_timestep is tracked in #252.)
with warnings.catch_warnings():
warnings.simplefilter("ignore", FutureWarning)
mesh.write_checkpoint(
mesh_safe,
outputPath=bulk_dir,
meshVars=mesh_vars,
index=0,
)
with _short_io_path(bulk_dir) as short_bulk_dir:
with warnings.catch_warnings():
warnings.simplefilter("ignore", FutureWarning)
mesh.write_checkpoint(
mesh_safe,
outputPath=short_bulk_dir,
meshVars=mesh_vars,
index=0,
)

mesh_records.append({
"name": mesh.name,
Expand Down Expand Up @@ -496,10 +520,13 @@ def read_snapshot(model, path: str) -> None:
f"snapshot variable {var_name!r} not registered on "
f"mesh {mesh_name!r}"
)
var.read_checkpoint(
os.path.join(bulk_dir, external_file),
data_name=var_name,
)
with _short_io_path(
os.path.join(bulk_dir, external_file)
) as short_external_file:
var.read_checkpoint(
short_external_file,
data_name=var_name,
)

# Phase 3a: restore state-bearer dataclasses.
if _GROUP_PYTHON_STATE in f:
Expand Down Expand Up @@ -949,13 +976,17 @@ def extract_var_via_bridge(wrapper_path: str, var_name: str):
# Rebuild a transient source mesh + variable to read DOFs into.
# We deliberately don't register them with the live model — these
# are throwaway and exit scope on return.
src_mesh = uw.discretisation.Mesh(os.path.join(bulk_dir, mesh_file_rel))
with _short_io_path(
os.path.join(bulk_dir, mesh_file_rel)
) as short_mesh_file:
src_mesh = uw.discretisation.Mesh(short_mesh_file)
src_var = uw.discretisation.MeshVariable(
var_name, src_mesh, components, degree=degree, continuous=continuous,
)
src_var.read_checkpoint(
os.path.join(bulk_dir, var_file_rel), data_name=var_name
)
with _short_io_path(
os.path.join(bulk_dir, var_file_rel)
) as short_var_file:
src_var.read_checkpoint(short_var_file, data_name=var_name)

coords = np.asarray(src_var.coords).copy()
values = np.asarray(src_var.array[...]).reshape(
Expand Down
39 changes: 39 additions & 0 deletions tests/test_0010_snapshot_disk_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,45 @@ def test_write_snapshot_produces_wrapper_and_bulk_dir(tmp_path):
assert any("V.00000.h5" in f for f in files)


def test_snapshot_bulk_filenames_do_not_expand_loaded_mesh_name(tmp_path):
"""A source pathname remains metadata, not a PETSc bulk filename."""
import h5py
import os

uw, model, mesh, T, V = _fresh_model_mesh_and_vars()
mesh.name = "/g/data/project/user/" + "nested_directory/" * 12 + "mesh.msh.h5"

path = str(tmp_path / "compact.snap.h5")
model.save_state(file=path)

bulk = str(tmp_path / "compact.snap.bulk")
files = sorted(os.listdir(bulk))
assert files
assert all(filename.startswith("mesh_0000.") for filename in files)
assert max(map(len, files)) < 64

with h5py.File(path, "r") as h5:
assert list(h5["meshes"]) == ["mesh_0000"]
assert h5["meshes"]["mesh_0000"].attrs["name"] == mesh.name


def test_short_io_path_uses_basename_and_restores_cwd(tmp_path):
"""Native PETSc/HDF5 calls do not receive the full user output path."""
import os
from underworld3.checkpoint.disk_snapshot import _short_io_path

original_directory = os.getcwd()
target_directory = tmp_path / "nested" / "snapshot.bulk"
target_directory.mkdir(parents=True)
target_file = target_directory / "mesh_0000.T.00000.h5"

with _short_io_path(str(target_file)) as short_path:
assert short_path == target_file.name
assert os.getcwd() == str(target_directory)

assert os.getcwd() == original_directory


def test_write_snapshot_populates_wrapper_layout(tmp_path):
"""The wrapper carries the per-mesh + per-variable metadata that
makes 'what's in this snapshot?' answerable from h5py alone."""
Expand Down
Loading