Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b8fceac
[bugfix] restore dataloader state before create_dataloader forks workers
tiankongdeguiji Jun 11, 2026
6e269d8
[test] create_dataloader checkpoint_state must reach forked workers
tiankongdeguiji Jun 11, 2026
c6bbfe5
[chore] bump version to 1.2.19
tiankongdeguiji Jun 11, 2026
5389e7f
[bugfix] copy checkpoint state into reader; consume it when a pass co…
tiankongdeguiji Jun 11, 2026
51f6cf4
[test] assert affirmative resume invariants in create_dataloader stat…
tiankongdeguiji Jun 11, 2026
1e472f6
[bugfix] reset accumulated dataloader state when a pass completes
tiankongdeguiji Jun 11, 2026
faa42a1
[feat] persist completed-pass count for exact epoch-budget resume
tiankongdeguiji Jun 11, 2026
9a8dd14
[chore] drop resume logging, tighten comments
tiankongdeguiji Jun 11, 2026
1352019
[chore] rename resume_own_model_dir to restore_from_model_dir
tiankongdeguiji Jun 11, 2026
587a992
[bugfix] persist epoch bookkeeping through the save dedupe; validate …
tiankongdeguiji Jun 18, 2026
e323234
[bugfix] handle multi-epoch resume edge cases in the train loop
tiankongdeguiji Jun 18, 2026
4ca7dd6
[test] cover get_iterator reuse and epoch-counter resume
tiankongdeguiji Jun 18, 2026
87ee083
Merge remote-tracking branch 'origin/master' into fix_dataloader_stat…
tiankongdeguiji Jun 18, 2026
11850e2
[chore] bump version to 1.2.20
tiankongdeguiji Jun 18, 2026
d433720
[chore] use dataloader.get_iterator() at all call sites; simplify com…
tiankongdeguiji Jun 18, 2026
5fafa37
[chore] maybe_save: copy state and stamp watermark once up front
tiankongdeguiji Jun 18, 2026
4aea0fc
[chore] drop epoch-counter int coercion on restore; tighten comment
tiankongdeguiji Jun 18, 2026
f9227ee
[chore] drop empty-pass guard on the optimizer-warmup peek
tiankongdeguiji Jun 18, 2026
abf7498
Merge remote-tracking branch 'origin/master' into fix_dataloader_stat…
tiankongdeguiji Jun 23, 2026
595d0d8
[chore] bump version to 1.2.21
tiankongdeguiji Jun 23, 2026
ee33f5a
Merge remote-tracking branch 'origin/master' into fix_dataloader_stat…
tiankongdeguiji Jun 23, 2026
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
8 changes: 8 additions & 0 deletions tzrec/datasets/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,7 @@ def create_dataloader(
mode: Mode = Mode.TRAIN,
gl_cluster: Optional[Dict[str, Union[int, str]]] = None,
debug_level: int = 0,
checkpoint_state: Optional[Dict[str, Any]] = None,
) -> DataLoader:
"""Build dataloader.

Expand All @@ -768,6 +769,11 @@ def create_dataloader(
gl_cluster (dict, bool): if set, reuse the graphlearn cluster.
debug_level (int): dataset debug level, when mode=predict and
debug_level > 0, will dump fg encoded data to debug_str
checkpoint_state (dict, optional): dataloader checkpoint state for
resume. Must be set here rather than on the returned dataloader:
the eager ``iter(dataloader)`` below forks persistent workers,
which keep a fork-time copy of the dataset, so state applied
afterwards never reaches them.

Return:
dataloader (dataloader): a DataLoader.
Expand All @@ -783,6 +789,8 @@ def create_dataloader(
mode=mode,
debug_level=debug_level,
)
if checkpoint_state:
dataset.load_state_dict(checkpoint_state)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BaseReader.load_state_dict stores this dict by reference (dataset.py:555), and train_and_evaluate passes the very same object on to _train_and_evaluate, which mutates it in place on every step via update_dataloder_state (main.py:462) and at save time. With num_workers >= 1 the fork-time copy makes this accidentally safe, but with data_config.num_workers < 1 the DataLoader runs in-process, so the reader aliases the live, ever-growing dict — the next iter(train_dataloader) (epoch 2) recomputes intervals from offsets accumulated during this run and skips that data. KafkaReader's on_assign rebalance callback also reads _checkpoint_state at arbitrary later times, cross-thread. A defensive copy breaks the aliasing:

Suggested change
if checkpoint_state:
dataset.load_state_dict(checkpoint_state)
if checkpoint_state:
dataset.load_state_dict(dict(checkpoint_state))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5389e7fcreate_dataloader now passes dict(checkpoint_state) to load_state_dict, decoupling the reader from the training loop's accumulating dict.


kwargs = {}
if data_config.num_workers < 1:
Expand Down
45 changes: 45 additions & 0 deletions tzrec/datasets/parquet_dataset_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from torch import distributed as dist
from torch.utils.data import DataLoader

from tzrec.datasets.dataset import create_dataloader
from tzrec.datasets.parquet_dataset import ParquetDataset, ParquetReader, ParquetWriter
from tzrec.features.feature import create_features
from tzrec.protos import data_pb2, feature_pb2
Expand Down Expand Up @@ -235,6 +236,50 @@ def test_parquet_dataset_checkpoint_resume(self):
# New offset should be less than or equal to acc checkpoint offset
self.assertLessEqual(new_offset, checkpoint_state_acc[key])

def test_create_dataloader_checkpoint_state_reaches_workers(self):
"""State passed to create_dataloader must reach forked workers.

create_dataloader eagerly starts persistent workers, so state applied
to the returned dataloader's dataset afterwards never reaches them;
the checkpoint_state argument applies it before the fork.
"""
feature_cfgs = self._create_feature_cfgs()
features = create_features(feature_cfgs)

with tempfile.TemporaryDirectory(prefix="tzrec_") as test_dir:
# 20000 rows at max_rows_per_file=5000 -> 4 files, so
# data_config.num_workers=2 survives the num_files clamp.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment justifies the fixture size with a constraint that doesn't apply: ParquetReader.num_files() returns None when rebalance=True (the default, and ParquetDataset doesn't override it), so the num_files clamp in create_dataloader never fires for ParquetDataset. ~2k rows would exercise the same paths faster; either shrink the fixture or fix the comment.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 51f6cf4 — comment now justifies the fixture by rebalanced multi-worker intervals + a remaining tail; kept the 20000 rows since the reworked assertions iterate full passes.

self._create_test_parquet_data(test_dir, num_rows=20000)
input_path = f"{test_dir}/*.parquet"
data_config = data_pb2.DataConfig(
batch_size=128,
dataset_type=data_pb2.DatasetType.ParquetDataset,
fg_mode=data_pb2.FgMode.FG_NONE,
label_fields=["label"],
num_workers=2,
)

dataloader1 = create_dataloader(data_config, features, input_path)
iterator1 = iter(dataloader1)
checkpoint_state = {}
for _ in range(4):
batch1 = next(iterator1)
update_dataloder_state(checkpoint_state, batch1.checkpoint_info)
self.assertGreater(len(checkpoint_state), 0)
del iterator1, dataloader1

dataloader2 = create_dataloader(
data_config, features, input_path, checkpoint_state=checkpoint_state
)
iterator2 = iter(dataloader2)
for _ in range(4):
batch2 = next(iterator2)
for key, new_offset in batch2.checkpoint_info.items():
if key in checkpoint_state:
# without the pre-fork state, workers replay from row 0
self.assertGreater(new_offset, checkpoint_state[key])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard makes the green path vacuous: when the fix works, resumed source keys are f"{input_path}:{consumed+1}" (calc_remaining_intervals starts remaining intervals at consumed + 1), which can never equal a saved key f"{input_path}:{start}" — so key in checkpoint_state is never true and the loop asserts nothing on all 4 batches. The test does still catch the targeted regression (without pre-fork state, workers emit the original keys :0/:10000 with offsets ≤ the saved ones, failing assertGreater), but adjacent regressions (off-by-N resume position, key-format drift) would pass silently. Asserting the affirmative invariant keeps the detection power and makes the pass path meaningful:

resumed_starts = set()
for _ in range(4):
    batch2 = next(iterator2)
    self.assertTrue(set(batch2.checkpoint_info).isdisjoint(checkpoint_state))
    resumed_starts |= {int(k.rsplit(":", 1)[1]) for k in batch2.checkpoint_info}
self.assertEqual(resumed_starts, {v + 1 for v in checkpoint_state.values()})

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 51f6cf4 — phase 2 now iterates to exhaustion and asserts the affirmative invariants: key disjointness, presence of every {path}:{consumed+1} resumed start, and row-count conservation against an empirically measured baseline (robust to slicing details). Re-verified detection power: under the old set-after-create pattern all three assertions fail (full 20000-row replay vs expected 19488).

del iterator2, dataloader2


class ParquetReaderTest(unittest.TestCase):
def setUp(self):
Expand Down
43 changes: 24 additions & 19 deletions tzrec/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,22 +588,6 @@ def train_and_evaluate(
# Build feature
features = _create_features(list(pipeline_config.feature_configs), data_config)

# Build dataloader
train_dataloader = create_dataloader(
data_config, features, pipeline_config.train_input_path, mode=Mode.TRAIN
)
eval_dataloader = None
if pipeline_config.eval_input_path:
# pyre-ignore [16]
gl_cluster = train_dataloader.dataset.get_sampler_cluster()
eval_dataloader = create_dataloader(
data_config,
features,
pipeline_config.eval_input_path,
mode=Mode.EVAL,
gl_cluster=gl_cluster,
)

ckpt_manager = checkpoint_util.CheckpointManager(
pipeline_config.model_dir,
keep_checkpoint_max=train_config.keep_checkpoint_max,
Expand Down Expand Up @@ -637,12 +621,33 @@ def train_and_evaluate(
"--continue_train)"
)

# Restore dataloader checkpoint state
# Restore dataloader checkpoint state before building the dataloader:
# create_dataloader eagerly starts persistent workers, which keep a
# fork-time copy of the dataset, so state set afterwards is invisible
# to them.
dataloader_state: Optional[Dict[str, Any]] = None
if ckpt_path and continue_train:
dataloader_state = ckpt_manager.restore_dataloader_state(ckpt_path)
if dataloader_state:
train_dataloader.dataset.load_state_dict(dataloader_state)

# Build dataloader
train_dataloader = create_dataloader(
data_config,
features,
pipeline_config.train_input_path,
mode=Mode.TRAIN,
checkpoint_state=dataloader_state,
)
Comment on lines +659 to +666

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that the state actually reaches the workers, note it is re-applied on every epoch, not just the resumed one: nothing ever clears _checkpoint_state, and each epoch's iter(train_dataloader) re-enters to_batchescalc_slice_intervals(checkpoint_state=...) (parquet_dataset.py:252-261). For num_epochs > 1 + --continue_train:

  1. After a mid-epoch resume, every subsequent epoch reads only the unconsumed tail instead of a full pass.
  2. dataloader_state accumulates across the whole run with no per-pass reset (main.py:462), so a checkpoint saved after one full pass marks the dataset fully consumed — resuming from it makes calc_remaining_intervals return [] and every epoch yields zero batches (with num_steps, the loop spins on immediate StopIteration and i_step never advances).

Pre-PR this was latent (the state never reached forked workers at all); this PR activates it in the default multi-worker config. One subtlety if you fix it with consume-once semantics: clearing on entry to to_batches would break the at-least-once re-seek this PR relies on (the eager iter() in create_dataloader plus the train loop's iter() are two resets that must both see the state) — clearing after the generator is fully exhausted (end of to_batches; a closed prefetch generator skips it) gives "finish the interrupted pass, then full passes". Alternatively, if resume is intentionally single-pass/streaming-only (kafka/odps), an explicit guard or a docstring caveat here and on create_dataloader would prevent silent data loss for multi-epoch configs.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5389e7f with the consume-once-on-exhaustion approach: BaseDataset.__iter__ clears the reader state after the wrapped to_batches generator exhausts normally (generator close() skips the clear, preserving the mid-pass re-seek that the eager iter() + train-loop iter() double reset relies on). Resumed pass finishes the tail, later epochs are full passes, and resuming from a fully-consumed checkpoint degrades to one empty pass instead of spinning. Verified the clear is safe for all readers (OdpsReader.load_state_dict guards if state:; Kafka's generator never exhausts, so streaming is unchanged) and covered by a new second-pass-reads-full-dataset assertion in the test.

eval_dataloader = None
if pipeline_config.eval_input_path:
# pyre-ignore [16]
gl_cluster = train_dataloader.dataset.get_sampler_cluster()
eval_dataloader = create_dataloader(
data_config,
features,
pipeline_config.eval_input_path,
mode=Mode.EVAL,
gl_cluster=gl_cluster,
)

sampler_type = _get_sampler_type(data_config)

Expand Down
2 changes: 1 addition & 1 deletion tzrec/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@
# See the License for the specific language governing permissions and
# limitations under the License.

__version__ = "1.2.18"
__version__ = "1.2.19"
Loading