Skip to content

Commit 56151a8

Browse files
authored
feat(mesh/io): zarr save/load for Mesh and DomainMesh via tensordict's zarr backend (NVIDIA#1894)
* feat(mesh/io): zarr save/load for Mesh and DomainMesh via tensordict backend Delegates all serialization to tensordict's upstream zarr storage backend (to_zarr/from_zarr, pytorch/tensordict#1754) and adds only what tensordict cannot know: chunk/compression policy aligned to training subsample sizes, and a root attr recording the mesh type so from_zarr can rebuild Mesh/DomainMesh from the plain PersistentTensorDict the backend returns. MeshReader/DomainMeshReader route zarr groups (zarr.json present) to mesh.io.from_zarr; everything else in the dataloader path is unchanged. Includes a scoped workaround for a tensordict bug where to_zarr chunks/compressors kwargs are silently dropped for nested leaves. * Address review: nested trees, recursive glob, zarr subsample push-down, tests - from_zarr: recursive _read_tree returning TensorDict, so nested TensorDict fields round-trip (review); shared by eager and windowed reads. - Readers: restore recursive '**' discovery (glob.glob recursive=True). - Subsample push-down for zarr stores: with subsample_n_cells/points set, readers fetch only the selected window (contiguous row-runs; cell meshes gather referenced points and compose measure weights) instead of materializing the full mesh. Bitwise-identical to eager load + in-memory subsample under the same seed. - test/mesh/io/io_zarr: round-trip, nested fields, point clouds, subgroup loads, layout policy on nested leaves, reader routing, push-down equivalence, mixed-format discovery, plus a committed golden store guarding on-disk format stability. - CHANGELOG entries for the feature and the glob fix. * Cache zarr store handles in readers; consolidate metadata on write Cold-metadata cost fix: readers re-opened each case's store per draw, walking the group-metadata chain -- on Lustre every uncached lookup is a metadata-server round-trip, so the first epochs on a metadata-cold client paid it per sample per epoch. Handles are now cached per reader (opened once per case), and to_zarr consolidates metadata so the one remaining open costs a single read instead of one per group. * Address review: drop upstream workaround, top-level tensordict import, fixture-generated format contract - Remove _propagate_nested_create_kwargs: fixed upstream in pytorch/tensordict#1759 (merge is gated on the tensordict pin, which will include it). - tensordict imported at module top (core dependency); drop the runtime backend checks for the same reason. - Replace the committed golden store with a session-fixture store plus explicit on-disk contract assertions (group paths, attrs, dtypes, chunking, codecs) -- format drift still fails a test, without binary files in the repo.
1 parent c0740bd commit 56151a8

8 files changed

Lines changed: 778 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010

1111
### Added
1212

13+
- Adds zarr save/load for `Mesh` and `DomainMesh` via tensordict's zarr
14+
storage backend: `physicsnemo.mesh.io.to_zarr` / `from_zarr`, with
15+
training-appropriate chunking and zstd compression. `MeshReader` and
16+
`DomainMeshReader` transparently read zarr stores alongside
17+
`.pmsh`/`.pdmsh` (opt in via `pattern`). Requires optional `zarr >= 3`
18+
and a tensordict release with the zarr backend.
1319
- Promotes GeoTransolver out of `experimental` to
1420
`physicsnemo.models.geotransolver.GeoTransolver`, together with the FLARE
1521
model (`physicsnemo.models.flare.FLARE`) and the reusable GALE and FLARE
@@ -373,6 +379,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
373379

374380
### Fixed
375381

382+
- `MeshReader` / `DomainMeshReader` sample discovery no longer uses
383+
`pathlib.Path.glob`, which can silently drop entries under filesystem
384+
metadata-server load (Lustre), causing training to proceed on a subset
385+
of the dataset.
376386
- `compute_cotan_weights_fem`, and the calculus, curvature, and smoothing
377387
routines built on it such as `Mesh.laplacian`, no longer fail on degenerate
378388
cells in float32. The Gram-matrix regularization is now scale-free, so it also

physicsnemo/datapipes/readers/mesh.py

Lines changed: 163 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from __future__ import annotations
2626

27+
import glob as _glob
2728
import logging
2829
from pathlib import Path
2930
from typing import Any, Iterator
@@ -128,6 +129,73 @@ def _subsample_mesh_cells(
128129
return mesh
129130

130131

132+
def _indices_to_runs(indices: torch.Tensor) -> list[tuple[int, int]]:
133+
"""Convert cyclic-block indices (1-2 ascending contiguous runs) to runs."""
134+
breaks = torch.nonzero(indices[1:] != indices[:-1] + 1).flatten()
135+
starts = [0] + [int(b) + 1 for b in breaks]
136+
ends = [int(b) + 1 for b in breaks] + [len(indices)]
137+
return [(int(indices[s]), int(indices[e - 1]) + 1) for s, e in zip(starts, ends)]
138+
139+
140+
def _zarr_mesh_subsampled(
141+
group,
142+
n_cells: int | None,
143+
n_points: int | None,
144+
generator: torch.Generator | None,
145+
) -> Mesh:
146+
"""Partial-read a zarr mesh group: fetch only the subsample window.
147+
148+
Reproduces :func:`_subsample_mesh` semantics (cyclic contiguous blocks,
149+
vertex compaction, Horvitz-Thompson measure weights) while reading only
150+
the selected rows from the store instead of materializing the full mesh.
151+
"""
152+
from physicsnemo.mesh.io import io_zarr as _ioz
153+
154+
total_cells = group["cells"].shape[0] if "cells" in group else 0
155+
total_points = group["points"].shape[0]
156+
157+
if total_cells > 0 and n_cells is not None and total_cells > n_cells:
158+
indices = _cyclic_block_indices(total_cells, n_cells, generator=generator)
159+
runs = _indices_to_runs(indices)
160+
cells = _ioz._read_rows(group["cells"], runs)
161+
# Compact: gather only referenced vertices; remap connectivity to the
162+
# sorted-unique order, matching slice_cells + slice_points.
163+
referenced, inverse = torch.unique(cells, return_inverse=True)
164+
cells = inverse.reshape(cells.shape)
165+
ref_np = referenced.numpy()
166+
mesh = Mesh(
167+
points=_ioz._read_index(group["points"], ref_np),
168+
cells=cells,
169+
point_data=_ioz._read_tree(
170+
group, "point_data", leaf_reader=lambda a: _ioz._read_index(a, ref_np)
171+
),
172+
cell_data=_ioz._read_tree(
173+
group, "cell_data", leaf_reader=lambda a: _ioz._read_rows(a, runs)
174+
),
175+
global_data=_ioz._read_tree(group, "global_data"),
176+
)
177+
compose_measure_weights(mesh, total_cells / n_cells)
178+
if n_points is not None:
179+
mesh = _subsample_mesh_points(mesh, n_points, generator=generator)
180+
return mesh
181+
182+
if total_cells == 0 and n_points is not None and total_points > n_points:
183+
indices = _cyclic_block_indices(total_points, n_points, generator=generator)
184+
runs = _indices_to_runs(indices)
185+
return Mesh(
186+
points=_ioz._read_rows(group["points"], runs),
187+
point_data=_ioz._read_tree(
188+
group, "point_data", leaf_reader=lambda a: _ioz._read_rows(a, runs)
189+
),
190+
cell_data=_ioz._read_tree(group, "cell_data"),
191+
global_data=_ioz._read_tree(group, "global_data"),
192+
)
193+
194+
# No subsampling applies (small mesh, or unsupported combination):
195+
# eager full read keeps semantics identical to the memmap path.
196+
return _ioz._mesh_from_group(group, None)
197+
198+
131199
def _subsample_mesh(
132200
mesh: Mesh,
133201
n_cells: int | None = None,
@@ -215,13 +283,49 @@ def __init__(
215283
if not self._root.is_dir():
216284
raise ValueError(f"Path must be a directory: {self._root}")
217285

218-
self._paths = sorted(self._root.glob(pattern))
286+
# glob.glob instead of Path.glob: the latter re-stats each entry and
287+
# silently drops entries under Lustre metadata-server load.
288+
self._paths = sorted(
289+
Path(p) for p in _glob.glob(str(self._root / pattern), recursive=True)
290+
)
219291
if not self._paths:
220292
raise ValueError(f"No paths matching {pattern!r} found in {self._root}")
221293

222294
def _load_sample(self, index: int) -> Mesh:
223295
"""Load a single Mesh from disk."""
224296
mesh_path = self._paths[index]
297+
if (mesh_path / "zarr.json").exists():
298+
from physicsnemo.mesh.io import from_zarr, io_zarr
299+
300+
if (
301+
self.subsample_n_cells is not None
302+
or self.subsample_n_points is not None
303+
):
304+
# Push the subsample into the read: fetch only the selected
305+
# window from the store. The generator derivation matches
306+
# __getitem__, so the draw is identical to subsampling after
307+
# an eager load (whose subsample then no-ops).
308+
# Cache opened store handles: re-opening walks the store's
309+
# group-metadata chain, and on networked filesystems every
310+
# uncached lookup is a metadata-server round-trip per draw.
311+
cache = getattr(self, "_zarr_groups", None)
312+
if cache is None:
313+
cache = self._zarr_groups = {}
314+
group = cache.get(mesh_path)
315+
if group is None:
316+
group = cache[mesh_path] = io_zarr._open_group(mesh_path)
317+
generator = (
318+
None
319+
if self._seed_base is None
320+
else spawn_generator(self._seed_base, self._epoch, index)
321+
)
322+
return _zarr_mesh_subsampled(
323+
group,
324+
self.subsample_n_cells,
325+
self.subsample_n_points,
326+
generator,
327+
)
328+
return from_zarr(mesh_path)
225329
return Mesh.load(mesh_path)
226330

227331
def _get_sample_metadata(self, index: int) -> dict[str, Any]:
@@ -406,13 +510,63 @@ def __init__(
406510
if not self._root.is_dir():
407511
raise ValueError(f"Path must be a directory: {self._root}")
408512

409-
self._paths = sorted(self._root.glob(pattern))
513+
# glob.glob instead of Path.glob: the latter re-stats each entry and
514+
# silently drops entries under Lustre metadata-server load.
515+
self._paths = sorted(
516+
Path(p) for p in _glob.glob(str(self._root / pattern), recursive=True)
517+
)
410518
if not self._paths:
411519
raise ValueError(f"No paths matching {pattern!r} found in {self._root}")
412520

413521
def _load_sample(self, index: int) -> DomainMesh:
414522
"""Load a single DomainMesh from disk."""
415-
return DomainMesh.load(self._paths[index])
523+
path = self._paths[index]
524+
if (path / "zarr.json").exists():
525+
from physicsnemo.mesh.io import from_zarr, io_zarr
526+
527+
if (
528+
self.subsample_n_cells is not None
529+
or self.subsample_n_points is not None
530+
):
531+
# Push the subsample into the read (window reads per
532+
# sub-mesh); drop flags are honored at read time so skipped
533+
# data is never fetched. Generator derivation and sub-mesh
534+
# order match __getitem__, whose subsample then no-ops.
535+
generator = (
536+
None
537+
if self._seed_base is None
538+
else spawn_generator(self._seed_base, self._epoch, index)
539+
)
540+
cache = getattr(self, "_zarr_groups", None)
541+
if cache is None:
542+
cache = self._zarr_groups = {}
543+
root = cache.get(path)
544+
if root is None:
545+
root = cache[path] = io_zarr._open_group(path)
546+
interior = _zarr_mesh_subsampled(
547+
root["interior"],
548+
self.subsample_n_cells,
549+
self.subsample_n_points,
550+
generator,
551+
)
552+
boundaries = {}
553+
if not self.drop_in_file_boundaries and "boundaries" in root:
554+
boundaries = {
555+
name: _zarr_mesh_subsampled(
556+
grp,
557+
self.subsample_n_cells,
558+
self.subsample_n_points,
559+
generator,
560+
)
561+
for name, grp in root["boundaries"].groups()
562+
}
563+
return DomainMesh(
564+
interior=interior,
565+
boundaries=boundaries,
566+
global_data=io_zarr._read_tree(root, "global_data"),
567+
)
568+
return from_zarr(path)
569+
return DomainMesh.load(path)
416570

417571
def __len__(self) -> int:
418572
return len(self._paths)
@@ -533,7 +687,12 @@ def _load_extra_boundaries(self, dm: DomainMesh, index: int) -> DomainMesh:
533687
glob_pattern,
534688
matches[0],
535689
)
536-
new_boundaries[bnd_name] = Mesh.load(matches[0])
690+
if (matches[0] / "zarr.json").exists():
691+
from physicsnemo.mesh.io import from_zarr
692+
693+
new_boundaries[bnd_name] = from_zarr(matches[0])
694+
else:
695+
new_boundaries[bnd_name] = Mesh.load(matches[0])
537696

538697
return DomainMesh(
539698
interior=dm.interior,

physicsnemo/mesh/io/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,20 @@
2727

2828
from typing import TYPE_CHECKING
2929

30-
__all__ = ["from_pyvista", "to_pyvista"]
30+
__all__ = ["from_pyvista", "to_pyvista", "from_zarr", "to_zarr"]
3131

3232
if TYPE_CHECKING:
3333
from physicsnemo.mesh.io.io_pyvista import from_pyvista, to_pyvista
34+
from physicsnemo.mesh.io.io_zarr import from_zarr, to_zarr
3435

3536

3637
def __getattr__(name: str): # PEP 562
3738
if name in {"from_pyvista", "to_pyvista"}:
3839
from physicsnemo.mesh.io import io_pyvista
3940

4041
return getattr(io_pyvista, name)
42+
if name in {"from_zarr", "to_zarr"}:
43+
from physicsnemo.mesh.io import io_zarr
44+
45+
return getattr(io_zarr, name)
4146
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

0 commit comments

Comments
 (0)