mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-14 07:42:28 +00:00
fix(python): commit streaming worker checkpoints on consumption (#4023)
## Summary - add `StreamingDataLoader`, which transports worker snapshots with prefetched batches and commits them to the parent dataset only when the trainer receives each batch - preserve exact non-uniform per-split progress and resume lagging splits without replaying already-consumed rows - reject stale parent checkpoints after a standard multi-process `DataLoader` has started, with guidance to use the consumer-aware loader - document the new public loader and merge non-uniform state across ranks ## Root cause PyTorch runs `StreamingDataset.__iter__` in private worker-process copies, while callers invoke `state_dict()` on the parent dataset. Sharing producer counters would still be incorrect because DataLoader prefetch can advance workers beyond batches returned to the trainer. ## Validation - `uv run --extra tests pytest python/tests/test_elastic_dataloader.py -q` (154 passed) - focused non-uniform merge regression (1 passed) - `uv run --project python --extra tests --extra dev ruff format .` - `uv run --project python --extra tests --extra dev ruff check .` - `cd docs && PYTHONPATH=. ../python/.venv/bin/mkdocs build` Fixes #3967 <!-- lance-gatekeeper-fix:v1 agent=572be272619660b97e87fd5c85188341 generation=1 --> --------- Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com> Co-authored-by: Xuanwo <github@xuanwo.io>
This commit is contained in:
co-authored by
Xuanwo
parent
94d484f539
commit
105fd73bc6
@@ -32,6 +32,7 @@ Parameters used throughout:
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
import threading
|
||||
from unittest.mock import patch
|
||||
|
||||
import lancedb
|
||||
@@ -46,6 +47,7 @@ from utils import (
|
||||
torch = pytest.importorskip("torch")
|
||||
streaming = pytest.importorskip("lancedb.streaming")
|
||||
StreamingDataset = streaming.StreamingDataset
|
||||
StreamingDataLoader = streaming.StreamingDataLoader
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataset parameters
|
||||
@@ -92,6 +94,27 @@ class FakeWorkerInfo:
|
||||
num_workers: int
|
||||
|
||||
|
||||
def _collate_with_first_batch_error(samples):
|
||||
ids = [sample["id"] for sample in samples]
|
||||
if ids == [0, 1]:
|
||||
raise ValueError("first batch fails")
|
||||
return ids
|
||||
|
||||
|
||||
def _collate_with_first_batch_stop(samples):
|
||||
ids = [sample["id"] for sample in samples]
|
||||
if ids == [0, 1]:
|
||||
raise StopIteration("first batch stopped")
|
||||
return ids
|
||||
|
||||
|
||||
def _collate_with_first_batch_interrupt(samples):
|
||||
ids = [sample["id"] for sample in samples]
|
||||
if ids == [0, 1]:
|
||||
raise KeyboardInterrupt("first batch interrupted")
|
||||
return ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1008,6 +1031,565 @@ def test_multi_worker_elastic_det_across_worker_counts(lance_table):
|
||||
# ── Resumability with num_workers ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_streaming_dataloader_commits_only_consumed_worker_batches(tmp_path):
|
||||
"""Prefetched worker state is committed only as the trainer receives it."""
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table(
|
||||
"worker_commit", pa.table({"id": [1, 2, 3, 4, 10, 20, 30, 40]})
|
||||
)
|
||||
dataset = StreamingDataset(table, num_splits=2, shuffle=False)
|
||||
loader = StreamingDataLoader(
|
||||
dataset,
|
||||
batch_size=2,
|
||||
num_workers=2,
|
||||
multiprocessing_context="spawn",
|
||||
prefetch_factor=4,
|
||||
)
|
||||
iterator = iter(loader)
|
||||
try:
|
||||
first = next(iterator)["id"].tolist()
|
||||
|
||||
assert first == [1, 2]
|
||||
assert dataset._checkpoint_snapshot()["samples_consumed_per_split"] == [2, 0]
|
||||
with pytest.raises(RuntimeError, match="complete logical step boundary"):
|
||||
dataset.state_dict()
|
||||
|
||||
second = next(iterator)["id"].tolist()
|
||||
assert second == [10, 20]
|
||||
checkpoint = dataset.state_dict()
|
||||
assert checkpoint["samples_consumed_per_split"] == [2, 2]
|
||||
uninterrupted = [batch["id"].tolist() for batch in iterator]
|
||||
finally:
|
||||
iterator._shutdown_workers()
|
||||
|
||||
resumed = StreamingDataset(table, num_splits=2, shuffle=False)
|
||||
resumed.load_state_dict(checkpoint)
|
||||
resumed_loader = StreamingDataLoader(
|
||||
resumed,
|
||||
batch_size=2,
|
||||
num_workers=2,
|
||||
multiprocessing_context="spawn",
|
||||
prefetch_factor=4,
|
||||
)
|
||||
resumed_iterator = iter(resumed_loader)
|
||||
try:
|
||||
remaining = [batch["id"].tolist() for batch in resumed_iterator]
|
||||
finally:
|
||||
resumed_iterator._shutdown_workers()
|
||||
assert remaining == uninterrupted == [[3, 4], [30, 40]]
|
||||
|
||||
|
||||
def test_distributed_checkpoint_uses_rank_local_worker_boundary(tmp_path):
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table("rank_boundary", pa.table({"id": list(range(8))}))
|
||||
dataset = StreamingDataset(
|
||||
table,
|
||||
num_splits=4,
|
||||
shuffle=False,
|
||||
rank=0,
|
||||
world_size=2,
|
||||
)
|
||||
loader = StreamingDataLoader(
|
||||
dataset,
|
||||
batch_size=1,
|
||||
num_workers=2,
|
||||
multiprocessing_context="spawn",
|
||||
)
|
||||
iterator = iter(loader)
|
||||
try:
|
||||
assert next(iterator)["id"].tolist() == [0]
|
||||
assert dataset._checkpoint_snapshot()["samples_consumed_per_split"] == [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
]
|
||||
with pytest.raises(RuntimeError, match="complete logical step boundary"):
|
||||
dataset.state_dict()
|
||||
|
||||
assert next(iterator)["id"].tolist() == [2]
|
||||
checkpoint = dataset.state_dict()
|
||||
remaining = [batch["id"].tolist() for batch in iterator]
|
||||
finally:
|
||||
iterator._shutdown_workers()
|
||||
|
||||
assert checkpoint["samples_consumed_per_split"] == [1, 1, 0, 0]
|
||||
assert remaining == [[1], [3]]
|
||||
|
||||
|
||||
def test_standard_dataloader_rejects_stale_parent_checkpoint(tmp_path):
|
||||
"""A standard DataLoader must not expose prefetched producer progress."""
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table("untracked_workers", pa.table({"id": [1, 2, 10, 20]}))
|
||||
dataset = StreamingDataset(table, num_splits=2, shuffle=False)
|
||||
# Merely constructing the checkpoint-aware loader must not authorize a
|
||||
# later plain DataLoader's worker progress.
|
||||
StreamingDataLoader(dataset, batch_size=2, num_workers=0)
|
||||
loader = torch.utils.data.DataLoader(
|
||||
dataset,
|
||||
batch_size=2,
|
||||
num_workers=2,
|
||||
multiprocessing_context="spawn",
|
||||
)
|
||||
iterator = iter(loader)
|
||||
try:
|
||||
assert next(iterator)["id"].tolist() == [1, 2]
|
||||
with pytest.raises(RuntimeError, match="Use StreamingDataLoader"):
|
||||
dataset.state_dict()
|
||||
list(iterator)
|
||||
finally:
|
||||
iterator._shutdown_workers()
|
||||
|
||||
|
||||
def test_streaming_dataloader_rejects_persistent_workers(tmp_path):
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table("persistent_workers", pa.table({"id": [1, 2]}))
|
||||
dataset = StreamingDataset(table, num_splits=2, shuffle=False)
|
||||
|
||||
with pytest.raises(ValueError, match="persistent_workers=True"):
|
||||
StreamingDataLoader(
|
||||
dataset,
|
||||
batch_size=1,
|
||||
num_workers=2,
|
||||
persistent_workers=True,
|
||||
)
|
||||
|
||||
|
||||
def test_collate_failure_invalidates_consumer_checkpoint(tmp_path):
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table(
|
||||
"collate_failure", pa.table({"id": [0, 1, 2, 3, 100, 101, 102, 103]})
|
||||
)
|
||||
dataset = StreamingDataset(table, num_splits=2, shuffle=False)
|
||||
loader = StreamingDataLoader(
|
||||
dataset,
|
||||
batch_size=2,
|
||||
num_workers=2,
|
||||
multiprocessing_context="spawn",
|
||||
collate_fn=_collate_with_first_batch_error,
|
||||
prefetch_factor=2,
|
||||
)
|
||||
iterator = iter(loader)
|
||||
try:
|
||||
with pytest.raises(ValueError, match="first batch fails"):
|
||||
next(iterator)
|
||||
assert next(iterator) == [100, 101]
|
||||
assert next(iterator) == [2, 3]
|
||||
with pytest.raises(RuntimeError, match="failed before it was returned"):
|
||||
dataset.state_dict()
|
||||
list(iterator)
|
||||
finally:
|
||||
iterator._shutdown_workers()
|
||||
|
||||
|
||||
def test_collate_stop_iteration_invalidates_consumer_checkpoint(tmp_path):
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table("collate_stop", pa.table({"id": list(range(6))}))
|
||||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||||
loader = StreamingDataLoader(
|
||||
dataset,
|
||||
batch_size=2,
|
||||
num_workers=0,
|
||||
collate_fn=_collate_with_first_batch_stop,
|
||||
)
|
||||
iterator = iter(loader)
|
||||
|
||||
with pytest.raises(RuntimeError, match="collate_fn raised StopIteration"):
|
||||
next(iterator)
|
||||
assert dataset._checkpoint_snapshot()["samples_consumed_per_split"] == [2]
|
||||
with pytest.raises(RuntimeError, match="failed before it was returned"):
|
||||
dataset.state_dict()
|
||||
assert list(iterator) == [[2, 3], [4, 5]]
|
||||
|
||||
|
||||
def test_batch_base_exception_invalidates_consumer_checkpoint(tmp_path):
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table("collate_interrupt", pa.table({"id": list(range(6))}))
|
||||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||||
loader = StreamingDataLoader(
|
||||
dataset,
|
||||
batch_size=2,
|
||||
num_workers=0,
|
||||
collate_fn=_collate_with_first_batch_interrupt,
|
||||
)
|
||||
iterator = iter(loader)
|
||||
|
||||
with pytest.raises(KeyboardInterrupt, match="first batch interrupted"):
|
||||
next(iterator)
|
||||
assert dataset._checkpoint_snapshot()["samples_consumed_per_split"] == [2]
|
||||
with pytest.raises(RuntimeError, match="failed before it was returned"):
|
||||
dataset.state_dict()
|
||||
assert list(iterator) == [[2, 3], [4, 5]]
|
||||
|
||||
|
||||
def test_parent_commit_base_exception_invalidates_consumer_checkpoint(tmp_path):
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table("commit_interrupt", pa.table({"id": list(range(4))}))
|
||||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||||
loader = StreamingDataLoader(dataset, batch_size=2, num_workers=0)
|
||||
iterator = iter(loader)
|
||||
real_commit = dataset._commit_worker_state
|
||||
|
||||
def interrupt_after_commit(state, *, require_uniform):
|
||||
real_commit(state, require_uniform=require_uniform)
|
||||
raise KeyboardInterrupt("after parent commit")
|
||||
|
||||
with patch.object(
|
||||
dataset, "_commit_worker_state", side_effect=interrupt_after_commit
|
||||
):
|
||||
with pytest.raises(KeyboardInterrupt, match="after parent commit"):
|
||||
next(iterator)
|
||||
|
||||
assert dataset._checkpoint_snapshot()["samples_consumed_per_split"] == [2]
|
||||
with pytest.raises(RuntimeError, match="failed before it was returned"):
|
||||
dataset.state_dict()
|
||||
|
||||
|
||||
def test_direct_iteration_surfaces_prefetch_failure_before_committing_row(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table("prefetch_failure", pa.table({"id": list(range(4))}))
|
||||
release = threading.Event()
|
||||
failed = threading.Event()
|
||||
real_getitems = streaming.Permutation.__getitems__
|
||||
|
||||
def controlled_getitems(permutation, indices):
|
||||
if indices and indices[0] >= 2:
|
||||
assert release.wait(timeout=5)
|
||||
failed.set()
|
||||
raise RuntimeError("later prefetched I/O failed")
|
||||
return real_getitems(permutation, indices)
|
||||
|
||||
class SignalDict(dict):
|
||||
def __setitem__(self, key, value):
|
||||
super().__setitem__(key, value)
|
||||
release.set()
|
||||
assert failed.wait(timeout=5)
|
||||
|
||||
monkeypatch.setattr(streaming.Permutation, "__getitems__", controlled_getitems)
|
||||
dataset = StreamingDataset(
|
||||
table,
|
||||
num_splits=1,
|
||||
shuffle=False,
|
||||
read_batch_size=2,
|
||||
io_queue_depth=2,
|
||||
)
|
||||
dataset._resume_positions = SignalDict()
|
||||
iterator = iter(dataset)
|
||||
|
||||
assert next(iterator)["id"] == 0
|
||||
with pytest.raises(RuntimeError, match="later prefetched I/O failed"):
|
||||
next(iterator)
|
||||
|
||||
checkpoint = dataset.state_dict()
|
||||
assert checkpoint["samples_consumed_per_split"] == [1]
|
||||
assert checkpoint["positions_consumed_per_split"] == [1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("workers", [0, 1, 2])
|
||||
def test_streaming_dataloader_rejects_drop_last(tmp_path, workers):
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table("drop_last", pa.table({"id": [0, 1, 2]}))
|
||||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||||
worker_options = {"multiprocessing_context": "spawn"} if workers else {}
|
||||
|
||||
with pytest.raises(ValueError, match="drop_last=True"):
|
||||
StreamingDataLoader(
|
||||
dataset,
|
||||
batch_size=2,
|
||||
num_workers=workers,
|
||||
drop_last=True,
|
||||
**worker_options,
|
||||
)
|
||||
|
||||
|
||||
def test_streaming_dataloader_owns_one_iterator_until_teardown(tmp_path):
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table("iterator_owner", pa.table({"id": list(range(4))}))
|
||||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||||
loader = StreamingDataLoader(
|
||||
dataset,
|
||||
batch_size=2,
|
||||
num_workers=1,
|
||||
multiprocessing_context="spawn",
|
||||
)
|
||||
|
||||
first = iter(loader)
|
||||
try:
|
||||
assert next(first)["id"].tolist() == [0, 1]
|
||||
with pytest.raises(RuntimeError, match="concurrent iteration"):
|
||||
iter(loader)
|
||||
finally:
|
||||
first._shutdown_workers()
|
||||
|
||||
second = iter(loader)
|
||||
try:
|
||||
assert [batch["id"].tolist() for batch in second] == [[2, 3]]
|
||||
except BaseException:
|
||||
second._shutdown_workers()
|
||||
raise
|
||||
|
||||
# Natural exhaustion releases ownership too.
|
||||
third = iter(loader)
|
||||
try:
|
||||
assert list(third) == []
|
||||
finally:
|
||||
third._shutdown_workers()
|
||||
|
||||
|
||||
def test_zero_worker_shutdown_closes_inner_iterator_before_release(tmp_path):
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table("zero_worker_shutdown", pa.table({"id": list(range(6))}))
|
||||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||||
loader = StreamingDataLoader(dataset, batch_size=2, num_workers=0)
|
||||
|
||||
first = iter(loader)
|
||||
assert next(first)["id"].tolist() == [0, 1]
|
||||
first._shutdown_workers()
|
||||
|
||||
assert dataset._consumer_iterator_active is False
|
||||
assert dataset._raw_batches_ref is None
|
||||
second = iter(loader)
|
||||
try:
|
||||
with pytest.raises(StopIteration):
|
||||
next(first)
|
||||
assert next(second)["id"].tolist() == [2, 3]
|
||||
finally:
|
||||
second._shutdown_workers()
|
||||
|
||||
|
||||
def test_direct_and_loader_admission_share_one_atomic_lease(tmp_path, monkeypatch):
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table("direct_loader_lease", pa.table({"id": list(range(4))}))
|
||||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||||
loader = StreamingDataLoader(dataset, batch_size=2, num_workers=0)
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
direct_result = []
|
||||
direct_error = []
|
||||
contender = []
|
||||
real_resolve = dataset._resolve_my_splits
|
||||
|
||||
def controlled_resolve():
|
||||
if threading.current_thread().name == "direct-start":
|
||||
entered.set()
|
||||
assert release.wait(timeout=5)
|
||||
return real_resolve()
|
||||
|
||||
def advance_direct(iterator):
|
||||
try:
|
||||
direct_result.append(next(iterator)["id"])
|
||||
except BaseException as exc:
|
||||
direct_error.append(exc)
|
||||
|
||||
monkeypatch.setattr(dataset, "_resolve_my_splits", controlled_resolve)
|
||||
direct = iter(dataset)
|
||||
thread = threading.Thread(
|
||||
target=advance_direct, args=(direct,), name="direct-start"
|
||||
)
|
||||
thread.start()
|
||||
assert entered.wait(timeout=5)
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="concurrent iteration"):
|
||||
contender.append(iter(loader))
|
||||
finally:
|
||||
release.set()
|
||||
thread.join(timeout=5)
|
||||
if contender:
|
||||
contender[0]._shutdown_workers()
|
||||
direct.close()
|
||||
|
||||
assert not thread.is_alive()
|
||||
assert direct_error == []
|
||||
assert direct_result == [0]
|
||||
|
||||
|
||||
def test_loader_acquires_before_snapshot_and_cleans_interrupted_acquire(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table("lease_snapshot", pa.table({"id": list(range(4))}))
|
||||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||||
loader = StreamingDataLoader(dataset, batch_size=2, num_workers=0)
|
||||
first = iter(loader)
|
||||
assert next(first)["id"].tolist() == [0, 1]
|
||||
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
pending = []
|
||||
pending_errors = []
|
||||
observed_snapshots = []
|
||||
real_acquire = dataset._acquire_consumer_iterator
|
||||
real_snapshot = dataset._checkpoint_snapshot
|
||||
|
||||
def controlled_acquire():
|
||||
if threading.current_thread().name == "stale-start":
|
||||
entered.set()
|
||||
assert release.wait(timeout=5)
|
||||
return real_acquire()
|
||||
|
||||
def recording_snapshot():
|
||||
state = real_snapshot()
|
||||
if threading.current_thread().name == "stale-start":
|
||||
observed_snapshots.append(state["samples_consumed_per_split"])
|
||||
return state
|
||||
|
||||
def create_pending_iterator():
|
||||
try:
|
||||
pending.append(iter(loader))
|
||||
except BaseException as exc:
|
||||
pending_errors.append(exc)
|
||||
|
||||
monkeypatch.setattr(dataset, "_acquire_consumer_iterator", controlled_acquire)
|
||||
monkeypatch.setattr(dataset, "_checkpoint_snapshot", recording_snapshot)
|
||||
thread = threading.Thread(target=create_pending_iterator, name="stale-start")
|
||||
thread.start()
|
||||
assert entered.wait(timeout=5)
|
||||
assert next(first)["id"].tolist() == [2, 3]
|
||||
with pytest.raises(StopIteration):
|
||||
next(first)
|
||||
release.set()
|
||||
thread.join(timeout=5)
|
||||
|
||||
assert not thread.is_alive()
|
||||
assert pending_errors == []
|
||||
assert observed_snapshots == [[4]]
|
||||
assert len(pending) == 1
|
||||
assert list(pending[0]) == []
|
||||
assert dataset.state_dict()["samples_consumed_per_split"] == [4]
|
||||
|
||||
def interrupted_acquire():
|
||||
real_acquire()
|
||||
raise KeyboardInterrupt("after acquire")
|
||||
|
||||
monkeypatch.setattr(dataset, "_acquire_consumer_iterator", interrupted_acquire)
|
||||
with pytest.raises(KeyboardInterrupt, match="after acquire"):
|
||||
iter(loader)
|
||||
assert dataset._consumer_iterator_active is False
|
||||
|
||||
|
||||
def test_consumer_iterator_lease_publication_is_atomic(tmp_path, monkeypatch):
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table("atomic_lease", pa.table({"id": [0, 1]}))
|
||||
dataset = StreamingDataset(table, num_splits=1, shuffle=False)
|
||||
loader = StreamingDataLoader(dataset, batch_size=1, num_workers=0)
|
||||
real_get_ident = streaming.threading.get_ident
|
||||
calls = 0
|
||||
|
||||
def interrupt_during_publication():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise KeyboardInterrupt("during lease mutation")
|
||||
return real_get_ident()
|
||||
|
||||
monkeypatch.setattr(streaming.threading, "get_ident", interrupt_during_publication)
|
||||
with pytest.raises(KeyboardInterrupt, match="during lease mutation"):
|
||||
iter(loader)
|
||||
monkeypatch.setattr(streaming.threading, "get_ident", real_get_ident)
|
||||
|
||||
assert dataset._consumer_iterator_active is False
|
||||
iterator = iter(loader)
|
||||
try:
|
||||
assert next(iterator)["id"].tolist() == [0]
|
||||
finally:
|
||||
iterator._shutdown_workers()
|
||||
|
||||
|
||||
def test_streaming_dataloader_rejects_dataset_iter_override(tmp_path):
|
||||
class CustomizedDataset(StreamingDataset):
|
||||
def __iter__(self):
|
||||
return iter([1000, 1001])
|
||||
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table("custom_iteration", pa.table({"id": [0, 1, 2]}))
|
||||
dataset = CustomizedDataset(table, num_splits=1, shuffle=False)
|
||||
|
||||
assert list(dataset) == [1000, 1001]
|
||||
with pytest.raises(TypeError, match="override __iter__"):
|
||||
StreamingDataLoader(
|
||||
dataset,
|
||||
batch_size=2,
|
||||
num_workers=0,
|
||||
collate_fn=list,
|
||||
)
|
||||
|
||||
|
||||
def test_interleaved_adapters_do_not_authorize_plain_iteration(tmp_path):
|
||||
db = lancedb.connect(tmp_path)
|
||||
table_a = db.create_table("adapter_a", pa.table({"id": [0, 1]}))
|
||||
table_b = db.create_table("adapter_b", pa.table({"id": [10, 11]}))
|
||||
dataset_a = StreamingDataset(table_a, num_splits=1, shuffle=False)
|
||||
dataset_b = StreamingDataset(table_b, num_splits=1, shuffle=False)
|
||||
initial_state = dataset_a.state_dict()
|
||||
|
||||
owner_a = dataset_a._acquire_consumer_iterator()
|
||||
owner_b = dataset_b._acquire_consumer_iterator()
|
||||
try:
|
||||
iterator_a = iter(streaming._StreamingDatasetAdapter(dataset_a))
|
||||
iterator_b = iter(streaming._StreamingDatasetAdapter(dataset_b))
|
||||
assert next(iterator_a).data["id"] == 0
|
||||
assert next(iterator_b).data["id"] == 10
|
||||
assert [sample.data["id"] for sample in iterator_a] == [1]
|
||||
assert [sample.data["id"] for sample in iterator_b] == [11]
|
||||
finally:
|
||||
dataset_a._release_consumer_iterator(owner_a)
|
||||
dataset_b._release_consumer_iterator(owner_b)
|
||||
|
||||
dataset_a.load_state_dict(initial_state)
|
||||
with patch(
|
||||
"lancedb.streaming.get_worker_info",
|
||||
return_value=FakeWorkerInfo(id=0, num_workers=1),
|
||||
):
|
||||
plain_iterator = iter(dataset_a)
|
||||
assert next(plain_iterator)["id"] == 0
|
||||
plain_iterator.close()
|
||||
|
||||
assert dataset_a._untracked_worker_iteration[0] == 1
|
||||
with pytest.raises(RuntimeError, match="Use StreamingDataLoader"):
|
||||
dataset_a.state_dict()
|
||||
|
||||
|
||||
def test_resume_from_partial_split_cycle_preserves_remaining_order(tmp_path):
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table("partial_cycle", pa.table({"id": [1, 2, 10, 20]}))
|
||||
dataset = StreamingDataset(table, num_splits=2, shuffle=False)
|
||||
iterator = iter(dataset)
|
||||
|
||||
assert next(iterator)["id"] == 1
|
||||
checkpoint = dataset.state_dict()
|
||||
iterator.close()
|
||||
assert checkpoint["samples_consumed_per_split"] == [1, 0]
|
||||
|
||||
resumed = StreamingDataset(table, num_splits=2, shuffle=False)
|
||||
resumed.load_state_dict(checkpoint)
|
||||
assert [row["id"] for row in resumed] == [10, 2, 20]
|
||||
|
||||
|
||||
def test_partial_cycle_resume_preserves_skip_truncation(tmp_path):
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table(
|
||||
"partial_skip", pa.table({"id": [0, 1, 2, 3, 100, 101, 102, 103]})
|
||||
)
|
||||
kwargs = dict(
|
||||
num_splits=2,
|
||||
shuffle=False,
|
||||
transform=_failing_transform({1, 2, 3}),
|
||||
on_transform_error="skip",
|
||||
)
|
||||
dataset = StreamingDataset(table, **kwargs)
|
||||
iterator = iter(dataset)
|
||||
|
||||
assert next(iterator)["id"] == 0
|
||||
checkpoint = dataset.state_dict()
|
||||
uninterrupted = [row["id"] for row in iterator]
|
||||
|
||||
resumed = StreamingDataset(table, **kwargs)
|
||||
resumed.load_state_dict(checkpoint)
|
||||
assert [row["id"] for row in resumed] == uninterrupted == [100]
|
||||
|
||||
|
||||
def test_multi_worker_resumability_same_topology(lance_table):
|
||||
"""Checkpoint with num_workers=2, resume with num_workers=2: exact continuation."""
|
||||
world_size = 1
|
||||
@@ -2018,6 +2600,23 @@ def test_merge_state_dicts_validates_consistency(lance_table):
|
||||
StreamingDataset.merge_state_dicts([])
|
||||
|
||||
|
||||
def test_merge_state_dicts_combines_nonuniform_consumer_progress(lance_table):
|
||||
dataset = StreamingDataset(
|
||||
lance_table, num_splits=2, shuffle=False, shuffle_seed=SHUFFLE_SEED
|
||||
)
|
||||
rank0 = dataset.state_dict()
|
||||
rank0["samples_consumed_per_split"] = [2, 0]
|
||||
rank0["positions_consumed_per_split"] = [2, 0]
|
||||
rank1 = dataset.state_dict()
|
||||
rank1["samples_consumed_per_split"] = [0, 2]
|
||||
rank1["positions_consumed_per_split"] = [0, 2]
|
||||
|
||||
merged = StreamingDataset.merge_state_dicts([rank0, rank1])
|
||||
|
||||
assert merged["samples_consumed_per_split"] == [2, 2]
|
||||
assert merged["positions_consumed_per_split"] == [2, 2]
|
||||
|
||||
|
||||
def test_load_state_dict_without_positions_key(lance_table):
|
||||
"""Checkpoints from before positions_consumed_per_split existed still
|
||||
resume exactly (positions equal sample counts when nothing is skipped)."""
|
||||
@@ -2254,6 +2853,65 @@ def test_pack_sequences_checkpoint_resumes_on_new_topology(tmp_path):
|
||||
]
|
||||
|
||||
|
||||
def test_packed_checkpoint_requires_complete_split_cycle(tmp_path):
|
||||
table = _create_token_table(tmp_path, [[1], [2], [10], [20]])
|
||||
dataset = _packed_dataset(table, pack_sequences=3, blocks_per_epoch=4, num_splits=2)
|
||||
iterator = iter(dataset)
|
||||
|
||||
next(iterator)
|
||||
with pytest.raises(RuntimeError, match="complete logical step boundary"):
|
||||
dataset.state_dict()
|
||||
|
||||
next(iterator)
|
||||
assert dataset.state_dict()["blocks_emitted_per_split"] == [1, 1]
|
||||
iterator.close()
|
||||
|
||||
|
||||
def test_streaming_dataloader_commits_consumed_packed_batches(tmp_path):
|
||||
table = _create_token_table(
|
||||
tmp_path,
|
||||
[[1], [2], [3], [4], [10], [20], [30], [40]],
|
||||
)
|
||||
kwargs = dict(pack_sequences=4, blocks_per_epoch=4, num_splits=2)
|
||||
dataset = _packed_dataset(table, **kwargs)
|
||||
loader = StreamingDataLoader(
|
||||
dataset,
|
||||
batch_size=1,
|
||||
num_workers=2,
|
||||
multiprocessing_context="spawn",
|
||||
prefetch_factor=2,
|
||||
)
|
||||
iterator = iter(loader)
|
||||
try:
|
||||
next(iterator)
|
||||
with pytest.raises(RuntimeError, match="complete logical step boundary"):
|
||||
dataset.state_dict()
|
||||
|
||||
next(iterator)
|
||||
checkpoint = dataset.state_dict()
|
||||
uninterrupted = [batch["input_ids"].tolist() for batch in iterator]
|
||||
finally:
|
||||
iterator._shutdown_workers()
|
||||
|
||||
resumed = _packed_dataset(table, **kwargs)
|
||||
resumed.load_state_dict(checkpoint)
|
||||
resumed_loader = StreamingDataLoader(
|
||||
resumed,
|
||||
batch_size=1,
|
||||
num_workers=2,
|
||||
multiprocessing_context="spawn",
|
||||
prefetch_factor=2,
|
||||
)
|
||||
resumed_iterator = iter(resumed_loader)
|
||||
try:
|
||||
remaining = [batch["input_ids"].tolist() for batch in resumed_iterator]
|
||||
finally:
|
||||
resumed_iterator._shutdown_workers()
|
||||
|
||||
assert checkpoint["blocks_emitted_per_split"] == [1, 1]
|
||||
assert remaining == uninterrupted
|
||||
|
||||
|
||||
def test_pack_sequences_validates_configuration_and_tokens(tmp_path):
|
||||
table = _create_token_table(tmp_path, [[1, 2]])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user