blob: d4d5674661602a6a9af6dba4ce0d8b83d31975c7 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import random
from collections import defaultdict
from unittest.mock import patch
import numpy as np
import pyarrow as pa
import pytest
import torch
import pypaimon.multimodal as pmm
from pypaimon.multimodal import window_dataset
@pytest.fixture
def frames(tmp_path):
connection = pmm.connect(options={"warehouse": str(tmp_path / "warehouse")})
table = connection.create_table("frames", schema=pa.schema([
pa.field("episode", pa.string()),
pa.field("step", pa.int64()),
pa.field("value", pa.float32()),
]), options={
"row-tracking.enabled": "true",
"data-evolution.enabled": "true",
"file.format": "parquet",
"scan.native-plan.enabled": "false",
"read.native.enabled": "false",
})
table.add([
{"episode": episode, "step": step, "value": float(step)}
for episode in ("a", "b") for step in range(24)
])
return table
def windows(frames, query=None, **kwargs):
options = dict(columns=["value"], group_key="episode", order_key="step",
frame_offsets={"value": [0, 1, 2]},
column_transforms={"value": torch.tensor})
options.update(kwargs)
return (query or frames.scan()).to_contiguous_window_dataset(**options)
def test_restore_rejects_same_length_different_anchors(frames):
original = windows(frames, anchor_row_ids=[0, 1])
different = windows(frames, anchor_row_ids=[2, 3])
assert len(original) == len(different)
with pytest.raises(ValueError, match="different CWD"):
different.load_state_dict(original.state_dict())
@pytest.mark.parametrize("change", ["context", "offsets", "padding", "snapshot"])
def test_restore_rejects_changed_data_contract(frames, change):
original = windows(frames, query=frames.scan().where("episode = 'a'"), boundary="pad")
state = original.state_dict()
options = {"query": frames.scan().where("episode = 'a'"), "boundary": "pad"}
if change == "context":
options["query"] = frames.scan().where("episode = 'b'")
elif change == "offsets":
options["frame_offsets"] = {"value": [2, 1, 0]}
elif change == "padding":
options["pad_values"] = {"value": -1}
else:
frames.add([{"episode": "c", "step": 0, "value": 0.0}])
options["query"] = frames.scan().where("episode = 'a'")
# A retained old snapshot remains valid after unrelated new commits.
pinned = frames.scan(snapshot_id=original.snapshot_id).where("episode = 'a'")
windows(frames, query=pinned, boundary="pad").load_state_dict(state)
different = windows(frames, **options)
assert len(original) == len(different)
with pytest.raises(ValueError, match="different CWD"):
different.load_state_dict(state)
class _RandomTransform:
def __init__(self):
self.generator = random.Random(37)
self.calls = [0]
def __call__(self, values):
self.calls[0] += 1
noise = (random.random() + np.random.random() + torch.rand(()).item()
+ self.generator.random() + self.calls[0])
return torch.tensor(values) + noise
def state_dict(self):
return {"generator": self.generator.getstate(), "calls": self.calls}
def load_state_dict(self, state):
self.generator.setstate(state["generator"])
self.calls[:] = state["calls"]
def test_restore_rng_and_independent_callback_state(frames):
original = windows(frames)
original.column_transforms["value"] = _RandomTransform()
original[0]
state = original.state_dict()
expected = original[1]["value"]
original[2]
restored = windows(frames)
restored.column_transforms["value"] = _RandomTransform()
restored.load_state_dict(state)
torch.testing.assert_close(restored[1]["value"], expected, rtol=0, atol=0)
def test_restore_rejects_missing_callback_and_unknown_version(frames):
original = windows(frames, column_transforms={"value": _RandomTransform()})
state = original.state_dict()
with pytest.raises(ValueError, match="different stateful callbacks"):
windows(frames).load_state_dict(state)
state["version"] = 2
with pytest.raises(ValueError, match="Unsupported CWD checkpoint version"):
original.load_state_dict(state)
def test_callback_requires_both_state_methods(frames):
transform = _RandomTransform()
transform.load_state_dict = None
dataset = windows(frames, column_transforms={"value": transform})
with pytest.raises(TypeError, match="must implement both"):
dataset.state_dict()
def test_checkpoint_reuses_identity_without_reading_payload(frames):
dataset = windows(frames)
with patch.object(dataset, "_read_rows", side_effect=AssertionError("payload read")):
state = dataset.state_dict()
with patch.object(window_dataset.hashlib, "sha256", side_effect=AssertionError("rehash")):
dataset.load_state_dict(state)
assert dataset.state_dict()["identity"] == state["identity"]
def test_restore_retained_tag_without_snapshot_file(frames):
frames.raw_table.create_tag("train")
dataset = windows(frames, query=frames.scan(tag_name="train"))
manager = frames.raw_table.snapshot_manager()
frames.raw_table.file_io.delete_quietly(manager.get_snapshot_path(dataset.snapshot_id))
state = dataset.state_dict()
restored = windows(frames, query=frames.scan(tag_name="train"))
restored.load_state_dict(state)
torch.testing.assert_close(restored[0]["value"], torch.tensor([0.0, 1.0, 2.0]))
@pytest.mark.parametrize("nested", [False, True])
def test_tensor_padding_identity_is_by_value(frames, nested):
def dataset(value):
pad = torch.tensor(value)
if nested:
pad = {"values": [pad]}
return windows(frames, boundary="pad", pad_values={"value": pad})
state = dataset(0.0).state_dict()
dataset(0.0).load_state_dict(state)
with pytest.raises(ValueError, match="different CWD"):
dataset(1.0).load_state_dict(state)
def test_numpy_padding_identity_ignores_memory_layout(frames):
pad = np.arange(6, dtype=np.float32).reshape(2, 3)
original = windows(frames, boundary="pad", pad_values={"value": pad})
restored = windows(frames, boundary="pad", pad_values={"value": np.asfortranarray(pad)})
restored.load_state_dict(original.state_dict())
def _loader(dataset, workers, persistent=False):
loader_type = pytest.importorskip("torchdata.stateful_dataloader").StatefulDataLoader
return loader_type(
dataset, batch_size=4, shuffle=True,
generator=torch.Generator().manual_seed(91), num_workers=workers,
persistent_workers=persistent,
**({"multiprocessing_context": "spawn", "prefetch_factor": 2} if workers else {}))
def _batch_values(batch):
return (batch["episode"], batch["step"].tolist(), batch["value"].tolist(),
batch["value_is_pad"].tolist())
class _EpisodeCounter:
def __init__(self):
self.counts = defaultdict(int)
def __call__(self, sample):
episode = sample["episode"]
self.counts[episode] += 1
return episode, sample["step"], self.counts[episode]
def state_dict(self):
return {"counts": self.counts}
def load_state_dict(self, state):
self.counts = state["counts"]
def test_stateful_loader_preserves_callback_dictionary_subclasses(frames, tmp_path):
loader_type = pytest.importorskip("torchdata.stateful_dataloader").StatefulDataLoader
def make_loader():
return loader_type(
windows(frames, adapter=_EpisodeCounter()), batch_size=4,
num_workers=1, multiprocessing_context="spawn", prefetch_factor=2)
def samples(batches):
return [(episode, step, count) for episodes, steps, counts in batches
for episode, step, count in zip(episodes, steps.tolist(), counts.tolist())]
loader = make_loader()
iterator = iter(loader)
prefix = samples([next(iterator) for _ in range(3)])
checkpoint = tmp_path / "loader.pt"
torch.save(loader.state_dict(), checkpoint)
suffix = samples(iterator)
assert prefix + suffix == [(episode, step, step + 1)
for episode in ("a", "b") for step in range(22)]
restored = make_loader()
restored.load_state_dict(torch.load(checkpoint, weights_only=False))
# Episode b was absent from the saved counters and still needs the default factory.
assert samples(restored) == suffix
@pytest.mark.parametrize("workers,persistent,completed", [
(0, False, 3), (2, False, 3), (2, True, 3), (0, False, 11), (2, True, 11),
])
def test_stateful_loader_resumes_prefetch_and_next_epoch(
frames, tmp_path, workers, persistent, completed):
dataset = windows(frames)
dataset.column_transforms["value"] = _RandomTransform()
loader = _loader(dataset, workers, persistent)
iterator = iter(loader)
prefix = [_batch_values(next(iterator)) for _ in range(completed)]
checkpoint = tmp_path / "loader.pt"
torch.save({"loader": loader.state_dict(), "generator": loader.generator.get_state()}, checkpoint)
remaining = [_batch_values(batch) for batch in iterator]
next_epoch = [_batch_values(batch) for batch in loader]
restored_dataset = windows(frames)
restored_dataset.column_transforms["value"] = _RandomTransform()
restored = _loader(restored_dataset, workers, persistent)
state = torch.load(checkpoint, weights_only=False)
restored.load_state_dict(state["loader"])
restored_iterator = iter(restored)
restored.generator.set_state(state["generator"])
assert [_batch_values(batch) for batch in restored_iterator] == remaining
assert [_batch_values(batch) for batch in restored] == next_epoch
assert sum(len(batch[0]) for batch in prefix + remaining) == len(dataset)
def test_stateful_distributed_sampler_restores_each_rank(frames):
stateful = pytest.importorskip("torchdata.stateful_dataloader")
samplers = pytest.importorskip("torchdata.stateful_dataloader.sampler")
all_ids = []
for rank in range(2):
def make_loader():
dataset = windows(frames)
sampler = samplers.StatefulDistributedSampler(
dataset, num_replicas=2, rank=rank, seed=71)
sampler.set_epoch(3)
return stateful.StatefulDataLoader(dataset, sampler=sampler, batch_size=4)
loader = make_loader()
iterator = iter(loader)
prefix = [_batch_values(next(iterator)) for _ in range(2)]
state = loader.state_dict()
suffix = [_batch_values(batch) for batch in iterator]
restored = make_loader()
restored.load_state_dict(state)
assert [_batch_values(batch) for batch in restored] == suffix
for episodes, steps, _, _ in prefix + suffix:
all_ids.extend(zip(episodes, steps))
assert len(all_ids) == len(set(all_ids)) == 44