Skip to content

Commit e37cc00

Browse files
authored
fix(data): preserve LMDB label availability across batches (#5839)
Fixes #5636 ## Summary - cache each LMDB frame availability signature from lightweight msgpack metadata - partition training, statistics, auto-probability, and distributed batches by both atom count and scalar `find_*` signature - allocate each auto-probability block target exactly across all homogeneous groups with deterministic integer largest-remainder apportionment - retain the existing scalar loss contract and reject heterogeneous collation defensively - reset pt_expt sampling after data requirements change - split full LMDB validation by atom count and availability so default-filled labels are excluded from metrics Simply stacking `find_*` values would not be safe here. Current PyTorch and dpmodel losses reduce a whole batch before applying scalar availability flags, and display/validation paths convert those flags to booleans. A vector flag would broadcast incorrectly, produce vector-valued losses, or fail boolean conversion. Homogeneous batching is the smaller compatible fix. ## Why existing tests missed this The direct collator test used `find_energy=1` in every frame and did not assert the flag value. All LMDB factories stored energy and force in every record, so same-nloc batching was also label-homogeneous by accident. Reader consistency tests inspect individual frames before collation, and loss tests construct homogeneous scalar flags without connecting them to LMDB batches. Auto-probability tests also used one availability signature, so they could not expose independent per-signature rounding that changed a block target of 3 into 4 sampled frames. Full-validation coverage only varied atom count. `LmdbTestData` therefore always observed globally present labels, and no test combined real labels with default-filled frames or checked order-independent optional-label metrics. ## Validation - common dpmodel LMDB, PyTorch LMDB, and full-validation suites: 137 passed, 3 subtests passed - isolated pt_expt LMDB data-system suite: 4 passed - review-focused common/PT/pt_expt LMDB suites: 36 passed through the pure-Python path (the locally installed optional PyTorch custom-op library has a stale ABI) - regression verifies an auto-probability target of 3 remains exactly 3 across two availability groups and sampler length stays exact - regression verifies energy and force defaults contribute zero loss when their corresponding `find_*` flag is zero - regression consumes the pre-requirement pt_expt iterator and verifies it is replaced before availability-homogeneous batching - regression verifies energy and force full-validation metrics use only their two labeled frames - `ruff format .` - `ruff check .` Coding agent: Codex Codex version: codex-cli 0.144.4 Model: gpt-5.6-sol Reasoning effort: xhigh <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - LMDB training batching now partitions by both atom count and consistent scalar label-availability flags, ensuring batches don’t mix incompatible `find_*` settings. - Batch collation now raises an error when label-availability flags conflict across frames. - Full validation now groups evaluation by atom count and label availability so default-filled/missing labels don’t skew metrics. - **Performance / Behavior** - LMDB dataset loaders are now built lazily and refresh immediately after adding new label requirements. - **Tests** - Added coverage for partially labeled LMDB batching, validator grouping, and block-target distribution across label-availability signatures. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: njzjz-bot <njzjz.bot@gmail.com>
1 parent dd0e511 commit e37cc00

8 files changed

Lines changed: 860 additions & 116 deletions

File tree

deepmd/dpmodel/utils/lmdb_data.py

Lines changed: 325 additions & 83 deletions
Large diffs are not rendered by default.

deepmd/pt/train/validation.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -284,10 +284,10 @@ def __init__(
284284
self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
285285
self._initialize_best_checkpoints(restart_training=restart_training)
286286

287-
# Lazily-populated full test snapshot for LMDB validation. Mixed-nloc
288-
# LMDB datasets cannot be stacked as a single (nframes, natoms*3)
289-
# tensor, so we materialize frames grouped by nloc the first time
290-
# full validation runs and reuse the snapshot on subsequent calls.
287+
# Lazily-populated full test snapshot for LMDB validation. Frames are
288+
# evaluated in atom-count and label-availability groups so each stack
289+
# has consistent shapes and scalar find_* flags. Reuse the decoded
290+
# snapshot on subsequent validation calls.
291291
self._lmdb_test_data: LmdbTestData | None = None
292292

293293
def should_run(self, display_step: int) -> bool:
@@ -430,20 +430,25 @@ def _iter_validation_data_systems(self) -> Iterator[Any]:
430430
and we forward its underlying ``DeepmdData`` instance.
431431
- For ``LmdbDataset`` validation data, we lazily materialize a
432432
:class:`LmdbTestData` snapshot (cached across calls) and yield one
433-
:class:`LmdbTestDataNlocView` per ``nloc`` group, so mixed-nloc
434-
frames can be stacked and evaluated group by group.
433+
:class:`LmdbTestDataNlocView` per atom-count and label-availability
434+
group. This keeps scalar ``find_*`` flags valid while excluding
435+
default-filled labels from metrics.
435436
"""
436437
validation_data = self.validation_data
437438
if isinstance(validation_data, LmdbDataset):
438439
lmdb_test_data = self._get_lmdb_test_data_snapshot(validation_data)
439-
for nloc in sorted(lmdb_test_data.nloc_groups.keys()):
440-
yield LmdbTestDataNlocView(lmdb_test_data, nloc)
440+
for (nloc, _signature), indices in sorted(
441+
lmdb_test_data.find_signature_groups.items()
442+
):
443+
yield LmdbTestDataNlocView(lmdb_test_data, nloc, indices)
441444
return
442445

443446
if hasattr(validation_data, "_reader"):
444447
lmdb_test_data = self._get_lmdb_test_data_snapshot(validation_data)
445-
for nloc in sorted(lmdb_test_data.nloc_groups.keys()):
446-
yield LmdbTestDataNlocView(lmdb_test_data, nloc)
448+
for (nloc, _signature), indices in sorted(
449+
lmdb_test_data.find_signature_groups.items()
450+
):
451+
yield LmdbTestDataNlocView(lmdb_test_data, nloc, indices)
447452
return
448453

449454
if hasattr(validation_data, "data_systems"):

deepmd/pt/utils/lmdb_dataset.py

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -171,24 +171,41 @@ def __init__(
171171
collate_fn=_collate_lmdb_batch,
172172
)
173173

174-
# Per-nloc-group dataloaders for make_stat_input.
175-
# Each group gets its own DataLoader so torch.cat in stat collection
176-
# only concatenates same-shape tensors.
177-
self._nloc_dataloaders: list[DataLoader] = []
174+
# Per-nloc and label-availability dataloaders for make_stat_input.
175+
# These are rebuilt after requirements are registered so statistics
176+
# never collate real labels with default-filled placeholders.
177+
self._nloc_dataloaders: list[DataLoader] | None = None
178+
179+
def _rebuild_nloc_dataloaders(self) -> None:
180+
"""Build homogeneous loaders used by model-stat collection."""
181+
dataloaders: list[DataLoader] = []
178182
for nloc in sorted(self._reader.nloc_groups.keys()):
179-
indices = self._reader.nloc_groups[nloc]
180-
subset = torch.utils.data.Subset(self, indices)
181-
bs = self._reader.get_batch_size_for_nloc(nloc)
182-
with torch.device("cpu"):
183-
dl = DataLoader(
184-
subset,
185-
batch_size=bs,
186-
shuffle=False,
187-
num_workers=0,
188-
drop_last=False,
189-
collate_fn=_collate_lmdb_batch,
190-
)
191-
self._nloc_dataloaders.append(dl)
183+
signature_groups = self._reader.group_indices_by_find_signature(
184+
self._reader.nloc_groups[nloc]
185+
)
186+
for signature in sorted(signature_groups):
187+
subset = torch.utils.data.Subset(self, signature_groups[signature])
188+
bs = self._reader.get_batch_size_for_nloc(nloc)
189+
with torch.device("cpu"):
190+
dl = DataLoader(
191+
subset,
192+
batch_size=bs,
193+
shuffle=False,
194+
num_workers=0,
195+
drop_last=False,
196+
collate_fn=_collate_lmdb_batch,
197+
)
198+
dataloaders.append(dl)
199+
self._nloc_dataloaders = dataloaders
200+
201+
def _get_nloc_dataloaders(self) -> list[DataLoader]:
202+
"""Materialize statistics loaders lazily when none are registered."""
203+
if self._nloc_dataloaders is None:
204+
self._rebuild_nloc_dataloaders()
205+
dataloaders = self._nloc_dataloaders
206+
if dataloaders is None:
207+
raise RuntimeError("Failed to initialize LMDB statistics dataloaders")
208+
return dataloaders
192209

193210
def __len__(self) -> int:
194211
return len(self._reader)
@@ -229,6 +246,7 @@ def data_requirements(self) -> list[DataRequirementItem]:
229246

230247
def add_data_requirement(self, data_requirement: list[DataRequirementItem]) -> None:
231248
self._reader.add_data_requirement(data_requirement)
249+
self._rebuild_nloc_dataloaders()
232250

233251
def preload_and_modify_all_data_torch(self) -> None:
234252
"""No-op: LMDB reads on demand."""
@@ -328,17 +346,17 @@ def batch_sizes(self) -> list[int]:
328346

329347
@property
330348
def systems(self) -> list:
331-
"""One 'system' per nloc group for stat collection compatibility."""
332-
return [self] * len(self._nloc_dataloaders)
349+
"""One logical system per stack-compatible statistics group."""
350+
return [self] * len(self._get_nloc_dataloaders())
333351

334352
@property
335353
def dataloaders(self) -> list:
336-
"""Per-nloc-group dataloaders for make_stat_input.
354+
"""Homogeneous dataloaders for make_stat_input.
337355
338-
Each dataloader yields batches with uniform nloc, so torch.cat
339-
in stat collection only concatenates same-shape tensors.
356+
Each loader has one nloc and one availability signature, so stat
357+
collection sees consistent shapes and scalar ``find_*`` flags.
340358
"""
341-
return self._nloc_dataloaders
359+
return self._get_nloc_dataloaders()
342360

343361
@property
344362
def sampler_list(self) -> list:

deepmd/pt_expt/utils/lmdb_dataset.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,9 @@ def add_data_requirements(
159159
self, data_requirement: list[DataRequirementItem]
160160
) -> None:
161161
self._reader.add_data_requirement(data_requirement)
162+
# Discard any iterator created under the previous availability
163+
# signature so the next batch uses the newly registered labels.
164+
self._iter = iter(self._sampler)
162165

163166
def get_nsystems(self) -> int:
164167
"""Return one logical LMDB training dataset."""

source/tests/common/dpmodel/test_lmdb_data.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,38 @@ def test_sampler_with_block_targets(self):
780780
self.assertGreater(len(all_indices), 600)
781781
self.assertEqual(len(set(all_indices)), 600)
782782

783+
def test_sampler_allocates_block_target_across_find_signatures(self):
784+
"""Independent signature groups must not each round the block target."""
785+
786+
class TwoSignatureReader:
787+
"""Minimal reader exposing two one-frame availability groups."""
788+
789+
def __init__(self):
790+
self.nloc_groups = {6: [0, 1]}
791+
self.frame_system_ids = [0, 0]
792+
793+
@staticmethod
794+
def group_indices_by_find_signature(indices):
795+
self.assertEqual(indices, [0, 1])
796+
return {(0.0,): [0], (1.0,): [1]}
797+
798+
@staticmethod
799+
def get_batch_size_for_nloc(nloc):
800+
self.assertEqual(nloc, 6)
801+
return 1
802+
803+
sampler = SameNlocBatchSampler(
804+
TwoSignatureReader(),
805+
shuffle=False,
806+
block_targets=[([0], 3)],
807+
)
808+
batches = list(sampler)
809+
counts = [sum(index in batch for batch in batches) for index in (0, 1)]
810+
811+
self.assertEqual(len(sampler), len(batches))
812+
self.assertEqual(sum(map(len, batches)), 3)
813+
self.assertEqual(sorted(counts), [1, 2])
814+
783815
def test_sampler_without_block_targets(self):
784816
reader = LmdbDataReader(self._lmdb_path, ["O", "H"])
785817
sampler = SameNlocBatchSampler(reader, shuffle=False)

0 commit comments

Comments
 (0)