This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 869a6d014d [python] Support CWD checkpoints with StatefulDataLoader 
(#10628)
869a6d014d is described below

commit 869a6d014d404d1d92d694a04b1741e4ea98f436
Author: Yann Byron <[email protected]>
AuthorDate: Tue Oct 20 12:10:52 2026 +0800

    [python] Support CWD checkpoints with StatefulDataLoader (#10628)
---
 docs/docs/pypaimon/pytorch.md                      | 116 ++++++++
 .../pypaimon/multimodal/window_dataset.py          | 112 +++++++-
 .../tests/contiguous_window_resume_test.py         | 292 +++++++++++++++++++++
 paimon-python/pyproject.toml                       |   1 +
 4 files changed, 520 insertions(+), 1 deletion(-)

diff --git a/docs/docs/pypaimon/pytorch.md b/docs/docs/pypaimon/pytorch.md
index 5deedfe8da..cc3b94e08b 100644
--- a/docs/docs/pypaimon/pytorch.md
+++ b/docs/docs/pypaimon/pytorch.md
@@ -320,6 +320,122 @@ physical file/split boundaries inside those episodes. If 
context has already
 been divided among ranks, use a local sampler instead of sharding it again
 with a world-size `DistributedSampler`; coordinate rank step counts separately.
 
+### Resuming a CWD training run
+
+Install `torchdata>=0.11,<0.12` alongside your compatible PyTorch installation.
+Use `torchdata.stateful_dataloader.StatefulDataLoader` in place of 
`DataLoader`.
+With `shuffle=True` it uses TorchData's stateful random sampler. It records the
+batch position delivered to training, including the worker states needed to
+reconstruct prefetched batches. Do not checkpoint a sampler's current index as
+the training position: prefetch can put it ahead of completed optimizer steps.
+
+CWD implements `state_dict()` and `load_state_dict()` for this loader. Its 
state
+checks the table, branch, resolved snapshot, actual context/anchor index, and
+window settings. Rebuild the same dataset before loading; restoration does not
+silently switch snapshots. Retain the snapshot files with a training tag for
+as long as checkpoints may be resumed. A changed snapshot, context, anchors,
+offsets or padding raises `ValueError`, even when the sample count is 
unchanged.
+The index identity is cached after its first checkpoint; do not mutate dataset
+configuration or index arrays during the run.
+
+CWD also saves Python, NumPy and Torch CPU global RNG state in each process.
+Transforms and adapters with private RNGs or other mutable state must implement
+both `state_dict()` and `load_state_dict(state)`. Their state is copied before
+it is returned. Keep callback code, normalization and preprocessing settings
+unchanged; CWD cannot infer these from an arbitrary callable. Custom collators
+must be stateless apart from those global RNGs. Reader caches and video decoder
+handles are rebuilt rather than checkpointed.
+
+The training loop must save model, optimizer and loader state together, after
+an optimizer update and before fetching another batch. The following pattern
+uses `make_dataset(snapshot_id)` to rebuild your CWD with the same window and
+preprocessing arguments, via `frames.scan(snapshot_id=snapshot_id)`:
+
+```python
+import os
+import random
+
+import numpy as np
+import torch
+from torchdata.stateful_dataloader import StatefulDataLoader
+
+loader_config = dict(batch_size=32, shuffle=True, drop_last=False,
+                     num_workers=4, prefetch_factor=2,
+                     persistent_workers=True, in_order=True)
+# Include the application's preprocessing/normalization version in this 
contract.
+run_config = dict(loader=loader_config, preprocessing="train-v1", world_size=1,
+                  sampler="torchdata.RandomSampler", seed=42)
+generator = torch.Generator().manual_seed(42)
+# Load only trusted checkpoint files, and choose their snapshot before 
planning.
+saved = (torch.load(resume_path, map_location="cpu", weights_only=False)
+         if resume_path is not None else None)
+snapshot_id = saved["snapshot_id"] if saved is not None else 
training_snapshot_id
+dataset = make_dataset(snapshot_id)
+loader = StatefulDataLoader(dataset, generator=generator, **loader_config)
+epoch, global_step = 0, 0
+
+# Restore model/optimizer/scheduler/scaler before restoring RNGs.
+if saved is not None:
+    if saved["config"] != run_config or saved["snapshot_id"] != 
dataset.snapshot_id:
+        raise ValueError("Training checkpoint configuration changed")
+    model.load_state_dict(saved["model"])
+    optimizer.load_state_dict(saved["optimizer"])
+    loader.load_state_dict(saved["loader"])
+    epoch, global_step = saved["epoch"], saved["global_step"]
+
+iterator = iter(loader)  # Dataset/worker state is restored here, not at 
load_state_dict.
+if saved is not None:
+    # Iterator construction consumes RNG; restore these after construction.
+    generator.set_state(saved["loader_generator"])
+    random.setstate(saved["python_rng"])
+    np.random.set_state(saved["numpy_rng"])
+    torch.set_rng_state(saved["torch_rng"])
+    if saved["cuda_rng"] is not None:
+        torch.cuda.set_rng_state_all(saved["cuda_rng"])
+
+for batch in iterator:
+    optimizer.zero_grad(set_to_none=True)
+    loss = training_loss(model, batch)
+    loss.backward()
+    optimizer.step()
+    global_step += 1
+    if should_checkpoint(global_step):
+        checkpoint = dict(
+            config=run_config, snapshot_id=dataset.snapshot_id,
+            epoch=epoch, global_step=global_step,
+            model=model.state_dict(), optimizer=optimizer.state_dict(),
+            loader=loader.state_dict(), loader_generator=generator.get_state(),
+            python_rng=random.getstate(), numpy_rng=np.random.get_state(),
+            torch_rng=torch.get_rng_state(),
+            cuda_rng=torch.cuda.get_rng_state_all() if 
torch.cuda.is_available() else None,
+        )
+        # Include scheduler/scaler states here when the trainer uses them.
+        torch.save(checkpoint, checkpoint_path + ".tmp")
+        os.replace(checkpoint_path + ".tmp", checkpoint_path)
+```
+
+Alternatively, use the same retained tag in `make_dataset` and verify its
+resolved ID. Move optimizer state to the training device as required by the
+trainer. The example saves inside the optimizer-step loop,
+including its last batch, before the iterator reports exhaustion. With 
TorchData
+0.11, saving an already exhausted iterator outside that loop is not equivalent:
+restoring it may immediately start a new epoch and consume another permutation.
+After finishing the restored suffix, start the next epoch with `iter(loader)`.
+
+Keep batch size, `drop_last`, sampler configuration, worker count, prefetch and
+persistent-worker settings unchanged, with `in_order=True`. Keep the same
+PyTorch/TorchData versions; checkpoint compatibility across versions is not
+promised. TorchData may regenerate sampling indices or replay prefetched work
+on restore; CWD does not promise constant-time recovery or zero rereads.
+
+For fixed-topology DDP, use TorchData's `StatefulDistributedSampler`, save one
+loader/generator/RNG state per rank at the same completed global step, and keep
+rank assignment and world size unchanged. Save its seed and epoch in the 
training
+configuration and call `sampler.set_epoch(saved_epoch)` before restoring the
+loader; the sampler's state does not itself contain the epoch. Cross-rank
+checkpoint publication, gradient-accumulation boundaries, scheduler/scaler 
state
+and deterministic model execution remain the trainer's responsibility.
+
 ## File Format Metadata Cache
 
 Reusable PyArrow Dataset metadata is cached across reads. Configure its 
estimated
diff --git a/paimon-python/pypaimon/multimodal/window_dataset.py 
b/paimon-python/pypaimon/multimodal/window_dataset.py
index 4a8209c955..f2240557e3 100644
--- a/paimon-python/pypaimon/multimodal/window_dataset.py
+++ b/paimon-python/pypaimon/multimodal/window_dataset.py
@@ -18,8 +18,11 @@
 """Snapshot-pinned PyTorch Dataset for contiguous Paimon row windows."""
 
 import copy
+import hashlib
 import math
 import operator
+import pickle
+import random
 from functools import partial
 
 import numpy as np
@@ -41,7 +44,7 @@ from pypaimon.read.query_auth_split import QueryAuthSplit
 from pypaimon.schema.data_types import (
     is_array_blob_type, is_blob_file_type, is_map_blob_type,
 )
-from pypaimon.snapshot.time_travel_util import SCAN_KEYS
+from pypaimon.snapshot.time_travel_util import SCAN_KEYS, TimeTravelUtil
 from pypaimon.table.special_fields import SpecialFields
 from pypaimon.utils.range import Range
 
@@ -205,6 +208,7 @@ class ContiguousWindowDataset(Dataset):
             for name in self._video_columns
         }
         self._build_index(index, anchor_row_ids)
+        self._checkpoint_identity = None
 
     @classmethod
     def from_query(cls, query, **kwargs):
@@ -256,6 +260,95 @@ class ContiguousWindowDataset(Dataset):
         state["_plans"] = {}
         return state
 
+    def state_dict(self):
+        """Return data identity and process state for StatefulDataLoader.
+
+        Rebuild this Dataset with the same retained snapshot, context, anchors,
+        and window settings before loading its state. The loader owns sampler
+        and batch progress; this Dataset never changes its index on restore.
+
+        Python, NumPy and Torch CPU global RNGs are saved for worker 
transforms.
+        Callbacks with private state must implement both ``state_dict`` and
+        ``load_state_dict``. Keep callback code and preprocessing configuration
+        unchanged; they cannot be inferred from a callable. The training loop
+        must also checkpoint its model, optimizer and main-process/CUDA RNGs.
+        Do not mutate the Dataset's configuration or index after checkpointing.
+        """
+        return {
+            "version": 1,
+            "identity": self._state_identity(),
+            "callbacks": {
+                # A tuple keeps TorchData from flattening dictionary 
subclasses.
+                name: (copy.deepcopy(callback.state_dict()),)
+                for name, callback in self._stateful_callbacks().items()
+            },
+            "python_rng": random.getstate(),
+            "numpy_rng": np.random.get_state(),
+            "torch_rng": torch.get_rng_state(),
+        }
+
+    def load_state_dict(self, state_dict):
+        """Reject checkpoints made from a different CWD data contract."""
+        if state_dict.get("version") != 1:
+            raise ValueError("Unsupported CWD checkpoint version.")
+        if state_dict.get("identity") != self._state_identity():
+            raise ValueError(
+                "Cannot restore state from a different CWD: rebuild the same "
+                "table snapshot, context, anchors and window settings.")
+        callbacks = self._stateful_callbacks()
+        if callbacks.keys() != state_dict["callbacks"].keys():
+            raise ValueError("CWD checkpoint has different stateful 
callbacks.")
+        for name, callback in callbacks.items():
+            
callback.load_state_dict(copy.deepcopy(state_dict["callbacks"][name][0]))
+        # Callback restoration may itself consume random numbers.
+        random.setstate(state_dict["python_rng"])
+        np.random.set_state(state_dict["numpy_rng"])
+        torch.set_rng_state(state_dict["torch_rng"])
+
+    def _stateful_callbacks(self):
+        callbacks = {"column:" + name: callback
+                     for name, callback in self.column_transforms.items()}
+        callbacks["adapter"] = self.adapter
+        stateful = {}
+        for name, callback in callbacks.items():
+            save = callable(getattr(callback, "state_dict", None))
+            restore = callable(getattr(callback, "load_state_dict", None))
+            if save != restore:
+                raise TypeError("CWD callback %s must implement both 
state_dict "
+                                "and load_state_dict." % name)
+            if save:
+                stateful[name] = callback
+        return stateful
+
+    def _state_identity(self):
+        if self._checkpoint_identity is None:
+            snapshot = (TimeTravelUtil.resolve_snapshot(self._table)
+                        if self.snapshot_id is not None else None)
+            masks = sorted(set(
+                tuple(sorted((split.auth_result.column_masking or {}).items()))
+                for split in self._splits if isinstance(split, 
QueryAuthSplit)))
+            metadata = (
+                self._table.table_path, self._table.current_branch(),
+                self.snapshot_id,
+                (snapshot.uuid, snapshot.base_manifest_list, 
snapshot.delta_manifest_list)
+                if snapshot is not None else None,
+                self._table.table_schema.id,
+                self.group_key, self.order_key, self._group_keys,
+                self.columns, tuple((name, self.frame_offsets[name]) for name 
in self.columns),
+                self.window_size, self.stride, self.boundary,
+                _checkpoint_padding(self.pad_values), self.video_backend, 
masks,
+            )
+            digest = hashlib.sha256()
+            for value in metadata:
+                digest.update(pickle.dumps(value, protocol=4))
+            for array in (self._row_ids, self._group_starts, 
self._group_lengths,
+                          self._group_first_orders, self._anchor_groups, 
self._anchor_starts):
+                digest.update(pickle.dumps((array.dtype.str, array.shape), 
protocol=4))
+                digest.update(memoryview(array))
+            # Worker snapshots are frequent; hash the immutable index only 
once.
+            self._checkpoint_identity = digest.hexdigest()
+        return self._checkpoint_identity
+
     def close(self):
         """Close video decoders opened by this Dataset process."""
         first_error = None
@@ -498,6 +591,23 @@ class ContiguousWindowDataset(Dataset):
         return plan
 
 
+def _checkpoint_padding(value):
+    # Tensor's default pickle includes storage identity, even for equal values.
+    if isinstance(value, torch.Tensor):
+        raw = value.detach().cpu().contiguous().reshape(-1).view(torch.uint8)
+        return ("tensor", str(value.dtype), tuple(value.shape), 
str(value.device),
+                value.requires_grad, raw.numpy().tobytes())
+    if type(value) is dict:
+        return ("dict", tuple(sorted(
+            (_checkpoint_padding(key), _checkpoint_padding(item))
+            for key, item in value.items())))
+    if type(value) in (list, tuple):
+        return (type(value).__name__, tuple(_checkpoint_padding(item) for item 
in value))
+    if type(value) is np.ndarray:
+        value = value.copy(order="C")
+    return ("pickle", pickle.dumps(value, protocol=4))
+
+
 class _PinnedRowIdPlan:
     """Read a projection from the window context's authorized splits by row ID.
 
diff --git a/paimon-python/pypaimon/tests/contiguous_window_resume_test.py 
b/paimon-python/pypaimon/tests/contiguous_window_resume_test.py
new file mode 100644
index 0000000000..d4d5674661
--- /dev/null
+++ b/paimon-python/pypaimon/tests/contiguous_window_resume_test.py
@@ -0,0 +1,292 @@
+# 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
+
+
[email protected]
+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())
+
+
[email protected]("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]))
+
+
[email protected]("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
+
+
[email protected]("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
diff --git a/paimon-python/pyproject.toml b/paimon-python/pyproject.toml
index 7d4c1b9ed0..c8761d5856 100644
--- a/paimon-python/pyproject.toml
+++ b/paimon-python/pyproject.toml
@@ -201,6 +201,7 @@ dev = [
 torch = [
     {include-group = "python-tests"},
     "pypaimon[torch,lerobot]",
+    "torchdata>=0.11,<0.12; python_version >= '3.10'",
 ]
 legacy = [
     {include-group = "test"},

Reply via email to