litdata 0.2.64__tar.gz → 0.2.65__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {litdata-0.2.64/src/litdata.egg-info → litdata-0.2.65}/PKG-INFO +1 -1
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/__about__.py +1 -1
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/constants.py +2 -0
- litdata-0.2.65/src/litdata/streaming/async_prefetch.py +256 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/config.py +139 -30
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/dataloader.py +13 -1
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/dataset.py +46 -40
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/downloader.py +149 -27
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/item_loader.py +296 -55
- litdata-0.2.65/src/litdata/streaming/reader.py +1090 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/serializers.py +17 -6
- litdata-0.2.65/src/litdata/streaming/timing.py +84 -0
- {litdata-0.2.64 → litdata-0.2.65/src/litdata.egg-info}/PKG-INFO +1 -1
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata.egg-info/SOURCES.txt +2 -0
- litdata-0.2.64/src/litdata/streaming/reader.py +0 -643
- {litdata-0.2.64 → litdata-0.2.65}/CONTRIBUTING.md +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/LICENSE +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/MANIFEST.in +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/README.md +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/requirements.txt +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/setup.cfg +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/setup.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/__init__.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/__main__.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/cli/__init__.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/cli/commands.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/cli/handler/__init__.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/cli/handler/cache.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/cli/handler/optimize.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/cli/parser.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/debugger.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/helpers.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/imports.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/processing/__init__.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/processing/data_processor.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/processing/functions.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/processing/readers.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/processing/utilities.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/raw/__init__.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/raw/dataset.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/raw/indexer.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/raw/types.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/requirements.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/__init__.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/cache.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/client.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/combined.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/compression.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/fs_provider.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/parallel.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/resolver.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/sampler.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/shuffle.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/writer.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/__init__.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/_pytree.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/base.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/breakpoint.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/broadcast.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/dataset_utilities.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/encryption.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/env.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/format.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/hf_dataset.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/packing.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/parquet.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/shuffle.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/subsample.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/torch_utils.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/train_test_split.py +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata.egg-info/dependency_links.txt +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata.egg-info/entry_points.txt +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata.egg-info/not-zip-safe +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata.egg-info/requires.txt +0 -0
- {litdata-0.2.64 → litdata-0.2.65}/src/litdata.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: litdata
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.65
|
|
4
4
|
Summary: The Deep Learning framework to train, deploy, and ship AI products Lightning fast.
|
|
5
5
|
Home-page: https://github.com/Lightning-AI/litdata
|
|
6
6
|
Download-URL: https://github.com/Lightning-AI/litdata
|
|
@@ -59,6 +59,8 @@ _PRINT_DEBUG_LOGS = bool(int(os.getenv("PRINT_DEBUG_LOGS", "0")))
|
|
|
59
59
|
_MAX_WAIT_TIME = int(os.getenv("MAX_WAIT_TIME", "120"))
|
|
60
60
|
_FORCE_DOWNLOAD_TIME = int(os.getenv("FORCE_DOWNLOAD_TIME", "30"))
|
|
61
61
|
_LITDATA_DISABLE_VERSION_CHECK = int(os.getenv("LITDATA_DISABLE_VERSION_CHECK", "0"))
|
|
62
|
+
# Experimental async chunk prefetch: set env ``LITDATA_ASYNC_CHUNK_PREFETCH=1``
|
|
63
|
+
# (see ``litdata.streaming.async_prefetch``). Off by default.
|
|
62
64
|
|
|
63
65
|
# DON'T CHANGE ORDER
|
|
64
66
|
_TORCH_DTYPES_MAPPING = {
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# Copyright The Lightning AI team.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
|
+
# you may not use this file except in compliance with the License.
|
|
4
|
+
# You may obtain a copy of the License at
|
|
5
|
+
#
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
|
+
#
|
|
8
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
9
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
10
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
|
+
# See the License for the specific language governing permissions and
|
|
12
|
+
# limitations under the License.
|
|
13
|
+
|
|
14
|
+
"""Experimental asyncio helpers for overlapping **remote chunk downloads**.
|
|
15
|
+
|
|
16
|
+
This is intentionally **not** an async ``StreamingDataLoader``. Training stays on
|
|
17
|
+
the sync ``for batch in loader`` API; decode stays on process workers. Asyncio is
|
|
18
|
+
only useful where we wait on network IO.
|
|
19
|
+
|
|
20
|
+
On by default for remote datasets; force off with ``LITDATA_ASYNC_CHUNK_PREFETCH=0``,
|
|
21
|
+
or force on locally with ``LITDATA_ASYNC_CHUNK_PREFETCH=1``.
|
|
22
|
+
|
|
23
|
+
Strategy:
|
|
24
|
+
* Prefer ``Downloader.adownload_file`` (streaming to disk) when overridden.
|
|
25
|
+
* Else ``Downloader.adownload_fileobj`` + atomic write.
|
|
26
|
+
* Otherwise run sync ``download_chunk_from_index`` in ``asyncio.to_thread`` and
|
|
27
|
+
``gather`` several chunk indexes — still overlaps latency for blocking cloud SDKs.
|
|
28
|
+
|
|
29
|
+
Real-S3 note (Studio benches on ~67MB ImageNet chunks): gather concurrency needs
|
|
30
|
+
``max_pre_download >= 4`` to matter; the prepare thread raises the floor when
|
|
31
|
+
async prefetch is enabled (see :func:`async_prefetch_min_pre_download`).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import contextlib
|
|
38
|
+
import logging
|
|
39
|
+
import os
|
|
40
|
+
import threading
|
|
41
|
+
from typing import TYPE_CHECKING
|
|
42
|
+
|
|
43
|
+
from litdata.streaming.downloader import Downloader
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from litdata.streaming.config import ChunksConfig
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger("litdata.streaming.async_prefetch")
|
|
49
|
+
|
|
50
|
+
# Thread-local event loop so PrepareChunksThread does not pay asyncio.run()
|
|
51
|
+
# startup on every drain batch.
|
|
52
|
+
_THREAD_LOOPS = threading.local()
|
|
53
|
+
|
|
54
|
+
# Empirically, async gather on real S3 is bottlenecked when max_pre_download==2
|
|
55
|
+
# (only 1–2 in-flight). Floor to 4 when the feature is enabled unless overridden.
|
|
56
|
+
_DEFAULT_ASYNC_MIN_PRE_DOWNLOAD = 4
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def async_chunk_prefetch_enabled(remote_dir: str | None = None) -> bool:
|
|
60
|
+
"""Return True when async chunk prefetch should run.
|
|
61
|
+
|
|
62
|
+
Env ``LITDATA_ASYNC_CHUNK_PREFETCH`` wins when set (``1``/``0``). When unset,
|
|
63
|
+
default **on** for remote datasets (real-S3: process+async is the fast path)
|
|
64
|
+
and **off** for local-only caches.
|
|
65
|
+
"""
|
|
66
|
+
raw = os.getenv("LITDATA_ASYNC_CHUNK_PREFETCH")
|
|
67
|
+
if raw is not None:
|
|
68
|
+
return bool(int(raw))
|
|
69
|
+
return bool(remote_dir)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def async_prefetch_min_pre_download() -> int:
|
|
73
|
+
"""Minimum ``max_pre_download`` applied when async chunk prefetch is on.
|
|
74
|
+
|
|
75
|
+
Override with ``LITDATA_ASYNC_MIN_PRE_DOWNLOAD`` (default 4). Set to ``0`` to
|
|
76
|
+
disable the floor and keep the caller's ``max_pre_download`` unchanged.
|
|
77
|
+
"""
|
|
78
|
+
raw = os.getenv("LITDATA_ASYNC_MIN_PRE_DOWNLOAD")
|
|
79
|
+
if raw is None:
|
|
80
|
+
return _DEFAULT_ASYNC_MIN_PRE_DOWNLOAD
|
|
81
|
+
return max(0, int(raw))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def apply_async_pre_download_floor(max_pre_download: int, remote_dir: str | None = None) -> int:
|
|
85
|
+
"""Raise ``max_pre_download`` when async prefetch needs gather width."""
|
|
86
|
+
if not async_chunk_prefetch_enabled(remote_dir):
|
|
87
|
+
return max_pre_download
|
|
88
|
+
floor = async_prefetch_min_pre_download()
|
|
89
|
+
if floor <= 0 or max_pre_download >= floor:
|
|
90
|
+
return max_pre_download
|
|
91
|
+
logger.info(
|
|
92
|
+
"Async chunk prefetch: raising max_pre_download %s → %s so "
|
|
93
|
+
"asyncio.gather can overlap remote chunk downloads. "
|
|
94
|
+
"Set LITDATA_ASYNC_MIN_PRE_DOWNLOAD=0 to keep the original value.",
|
|
95
|
+
max_pre_download,
|
|
96
|
+
floor,
|
|
97
|
+
)
|
|
98
|
+
return floor
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def downloader_supports_adownload(downloader: Downloader | None) -> bool:
|
|
102
|
+
"""True when an async download path is available on ``downloader``."""
|
|
103
|
+
if downloader is None:
|
|
104
|
+
return False
|
|
105
|
+
cls = type(downloader)
|
|
106
|
+
return (
|
|
107
|
+
cls.adownload_file is not Downloader.adownload_file or cls.adownload_fileobj is not Downloader.adownload_fileobj
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _remote_join(remote_dir: str, filename: str) -> str:
|
|
112
|
+
"""Join cloud URLs without ``os.path.join`` quirks on schemes."""
|
|
113
|
+
return remote_dir.rstrip("/") + "/" + filename.lstrip("/")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def _adownload_file_to_path(downloader: Downloader, remote_filepath: str, local_filepath: str) -> None:
|
|
117
|
+
"""Fetch ``remote_filepath`` asynchronously and publish atomically."""
|
|
118
|
+
if os.path.exists(local_filepath):
|
|
119
|
+
return
|
|
120
|
+
# Prefer streaming-to-disk when the backend overrides adownload_file.
|
|
121
|
+
if type(downloader).adownload_file is not Downloader.adownload_file:
|
|
122
|
+
await downloader.adownload_file(remote_filepath, local_filepath)
|
|
123
|
+
return
|
|
124
|
+
data = await downloader.adownload_fileobj(remote_filepath)
|
|
125
|
+
if data is None:
|
|
126
|
+
raise NotImplementedError(
|
|
127
|
+
f"{type(downloader).__name__}.adownload_fileobj returned None; "
|
|
128
|
+
"cannot use async chunk prefetch for this backend."
|
|
129
|
+
)
|
|
130
|
+
tmp_path = downloader._temp_download_path(local_filepath)
|
|
131
|
+
try:
|
|
132
|
+
os.makedirs(os.path.dirname(local_filepath) or ".", exist_ok=True)
|
|
133
|
+
with open(tmp_path, "wb") as f:
|
|
134
|
+
f.write(data)
|
|
135
|
+
downloader._atomic_replace(tmp_path, local_filepath)
|
|
136
|
+
except Exception:
|
|
137
|
+
with contextlib.suppress(FileNotFoundError, PermissionError):
|
|
138
|
+
os.remove(tmp_path)
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def _adownload_chunk_index(config: ChunksConfig, chunk_index: int) -> None:
|
|
143
|
+
"""Async download + decompress for one chunk index (mirrors sync config path)."""
|
|
144
|
+
assert config._chunks is not None
|
|
145
|
+
downloader = config._downloader
|
|
146
|
+
if downloader is None:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
chunk_filename = config._chunks[chunk_index]["filename"]
|
|
150
|
+
local_chunkpath = os.path.join(config._cache_dir, chunk_filename)
|
|
151
|
+
remote_chunkpath = _remote_join(downloader._remote_dir, chunk_filename)
|
|
152
|
+
lazily_ref_counted = chunk_index not in config._shared_chunk_indexes
|
|
153
|
+
lock_path = (
|
|
154
|
+
local_chunkpath.replace(f".{config._compressor_name}", "") if config._compressor_name else local_chunkpath
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if os.path.exists(local_chunkpath):
|
|
158
|
+
config.try_decompress(local_chunkpath)
|
|
159
|
+
if lazily_ref_counted:
|
|
160
|
+
downloader._increment_local_lock(lock_path, chunk_index)
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
if lazily_ref_counted:
|
|
164
|
+
downloader._increment_local_lock(lock_path, chunk_index)
|
|
165
|
+
|
|
166
|
+
if downloader_supports_adownload(downloader):
|
|
167
|
+
await _adownload_file_to_path(downloader, remote_chunkpath, local_chunkpath)
|
|
168
|
+
else:
|
|
169
|
+
# Overlap blocking SDK calls across threads when native async is unavailable.
|
|
170
|
+
await asyncio.to_thread(downloader.download_chunk_from_index, chunk_index)
|
|
171
|
+
|
|
172
|
+
config.try_decompress(local_chunkpath)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def adownload_chunk_indexes(config: ChunksConfig, chunk_indexes: list[int]) -> None:
|
|
176
|
+
"""Download several chunk indexes concurrently (gather)."""
|
|
177
|
+
if not chunk_indexes:
|
|
178
|
+
return
|
|
179
|
+
if len(chunk_indexes) == 1:
|
|
180
|
+
await _adownload_chunk_index(config, chunk_indexes[0])
|
|
181
|
+
return
|
|
182
|
+
await asyncio.gather(*[_adownload_chunk_index(config, idx) for idx in chunk_indexes])
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _thread_event_loop() -> asyncio.AbstractEventLoop:
|
|
186
|
+
"""Return a reusable event loop for the current thread."""
|
|
187
|
+
loop = getattr(_THREAD_LOOPS, "loop", None)
|
|
188
|
+
if loop is None or loop.is_closed():
|
|
189
|
+
loop = asyncio.new_event_loop()
|
|
190
|
+
_THREAD_LOOPS.loop = loop
|
|
191
|
+
return loop
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def close_thread_event_loop() -> None:
|
|
195
|
+
"""Shut down the thread-local loop and its default executor.
|
|
196
|
+
|
|
197
|
+
``asyncio.to_thread`` (used when a downloader has no native async path) parks
|
|
198
|
+
workers named ``asyncio_N`` on the loop's default executor. Leaving the loop
|
|
199
|
+
open leaks those threads and trips the session thread-police on Windows.
|
|
200
|
+
|
|
201
|
+
Executor shutdown is non-blocking (``wait=False``) so prepare-thread ``finally``
|
|
202
|
+
never joins forever if a download worker is stuck. Threads are marked daemon and
|
|
203
|
+
futures cancelled so orphans cannot keep the process alive or poison later
|
|
204
|
+
DataLoader forks under pytest-xdist.
|
|
205
|
+
"""
|
|
206
|
+
loop = getattr(_THREAD_LOOPS, "loop", None)
|
|
207
|
+
_THREAD_LOOPS.loop = None
|
|
208
|
+
if loop is None or loop.is_closed():
|
|
209
|
+
return
|
|
210
|
+
try:
|
|
211
|
+
if not loop.is_running():
|
|
212
|
+
with contextlib.suppress(Exception):
|
|
213
|
+
loop.run_until_complete(loop.shutdown_asyncgens())
|
|
214
|
+
# Prefer non-blocking teardown over ``shutdown_default_executor()``,
|
|
215
|
+
# which joins executor threads and can hang prepare-thread exit.
|
|
216
|
+
executor = getattr(loop, "_default_executor", None)
|
|
217
|
+
if executor is not None:
|
|
218
|
+
with contextlib.suppress(Exception):
|
|
219
|
+
loop._default_executor = None
|
|
220
|
+
for thread in getattr(executor, "_threads", set()):
|
|
221
|
+
thread.daemon = True
|
|
222
|
+
# cancel_futures is 3.9+; litdata already requires newer Python.
|
|
223
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
224
|
+
finally:
|
|
225
|
+
with contextlib.suppress(Exception):
|
|
226
|
+
loop.close()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def download_chunk_indexes_concurrently(config: ChunksConfig, chunk_indexes: list[int]) -> None:
|
|
230
|
+
"""Sync entry point for ``PrepareChunksThread``: run ``adownload_chunk_indexes``."""
|
|
231
|
+
if not chunk_indexes:
|
|
232
|
+
return
|
|
233
|
+
if len(chunk_indexes) == 1:
|
|
234
|
+
config.download_chunk_from_index(chunk_indexes[0])
|
|
235
|
+
return
|
|
236
|
+
# Reuse a per-thread loop instead of asyncio.run() (which creates+closes a loop
|
|
237
|
+
# on every prefetch batch — measurable overhead under high chunk churn).
|
|
238
|
+
try:
|
|
239
|
+
running = asyncio.get_running_loop()
|
|
240
|
+
except RuntimeError:
|
|
241
|
+
running = None
|
|
242
|
+
if running is not None:
|
|
243
|
+
# Already inside an event loop (tests / nested callers): block via a bridge.
|
|
244
|
+
import concurrent.futures
|
|
245
|
+
|
|
246
|
+
def _run() -> None:
|
|
247
|
+
try:
|
|
248
|
+
_thread_event_loop().run_until_complete(adownload_chunk_indexes(config, chunk_indexes))
|
|
249
|
+
finally:
|
|
250
|
+
close_thread_event_loop()
|
|
251
|
+
|
|
252
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
253
|
+
pool.submit(_run).result()
|
|
254
|
+
return
|
|
255
|
+
loop = _thread_event_loop()
|
|
256
|
+
loop.run_until_complete(adownload_chunk_indexes(config, chunk_indexes))
|
|
@@ -15,10 +15,14 @@ import contextlib
|
|
|
15
15
|
import logging
|
|
16
16
|
import os
|
|
17
17
|
from collections import defaultdict
|
|
18
|
+
from contextlib import suppress
|
|
18
19
|
from time import sleep, time
|
|
19
20
|
from typing import Any, Optional
|
|
20
21
|
|
|
22
|
+
from filelock import FileLock, Timeout
|
|
23
|
+
|
|
21
24
|
from litdata.constants import _INDEX_FILENAME, _MAX_WAIT_TIME
|
|
25
|
+
from litdata.debugger import _get_log_msg
|
|
22
26
|
from litdata.streaming.compression import _COMPRESSORS, Compressor
|
|
23
27
|
from litdata.streaming.downloader import get_downloader
|
|
24
28
|
from litdata.streaming.item_loader import BaseItemLoader, Interval, PyTreeLoader, TokensLoader
|
|
@@ -107,7 +111,14 @@ class ChunksConfig:
|
|
|
107
111
|
self._compressor = _COMPRESSORS[self._compressor_name]
|
|
108
112
|
|
|
109
113
|
self._skip_chunk_indexes_deletion: list[int] | None = None
|
|
114
|
+
# Chunk indexes that are shared across workers on this node. Shared chunks are
|
|
115
|
+
# reference-counted *eagerly* (incremented at iteration start by the reader), so their
|
|
116
|
+
# lazy download-time increment is skipped to keep the count balanced. See reader.py.
|
|
117
|
+
self._shared_chunk_indexes: set[int] = set()
|
|
110
118
|
self.zero_based_roi: list[tuple[int, int]] | None = None
|
|
119
|
+
# Memoizes ``__getitem__`` results per chunk_index (invariant once the config is loaded);
|
|
120
|
+
# avoids rebuilding the chunk path on every item read.
|
|
121
|
+
self._chunk_meta_cache: dict[int, tuple[str, int, int]] = {}
|
|
111
122
|
self.filename_to_size_map: dict[str, int] = {}
|
|
112
123
|
for cnk in _original_chunks:
|
|
113
124
|
# since files downloaded while reading will be decompressed, we need to store the name without compression
|
|
@@ -119,6 +130,67 @@ class ChunksConfig:
|
|
|
119
130
|
return True
|
|
120
131
|
return chunk_index not in self._skip_chunk_indexes_deletion
|
|
121
132
|
|
|
133
|
+
def _chunk_lock_filepath(self, chunk_index: int) -> str:
|
|
134
|
+
"""The (decompressed) local chunk path whose ``.cnt``/``.lock`` files hold the refcount."""
|
|
135
|
+
chunk_filepath, _, _ = self[ChunkedIndex(index=-1, chunk_index=chunk_index)]
|
|
136
|
+
return chunk_filepath
|
|
137
|
+
|
|
138
|
+
def remaining_locks(self, chunk_index: int) -> int:
|
|
139
|
+
"""Return the current reference count held on a chunk (0 if none)."""
|
|
140
|
+
countpath = self._chunk_lock_filepath(chunk_index) + ".cnt"
|
|
141
|
+
if not os.path.exists(countpath):
|
|
142
|
+
return 0
|
|
143
|
+
with suppress(FileNotFoundError), open(countpath) as count_f:
|
|
144
|
+
try:
|
|
145
|
+
return int(count_f.read().strip())
|
|
146
|
+
except Exception:
|
|
147
|
+
return 1
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
def increment_local_lock(self, chunk_index: int) -> None:
|
|
151
|
+
"""Add one reference to a chunk's local lock (a co-reader intends to use it)."""
|
|
152
|
+
if self._downloader is None:
|
|
153
|
+
return
|
|
154
|
+
self._downloader._increment_local_lock(self._chunk_lock_filepath(chunk_index), chunk_index)
|
|
155
|
+
|
|
156
|
+
def decrement_local_lock(self, chunk_index: int) -> int:
|
|
157
|
+
"""Remove one reference from a chunk's local lock; return the remaining count.
|
|
158
|
+
|
|
159
|
+
Moved here (from ``PrepareChunksThread``) so the reader can release eagerly-acquired locks
|
|
160
|
+
during teardown without depending on the prefetch thread still being alive.
|
|
161
|
+
"""
|
|
162
|
+
countpath = self._chunk_lock_filepath(chunk_index) + ".cnt"
|
|
163
|
+
lock_path = countpath + ".lock"
|
|
164
|
+
curr_count = 0
|
|
165
|
+
remove_lock = False
|
|
166
|
+
with suppress(Timeout, FileNotFoundError), FileLock(lock_path, timeout=3):
|
|
167
|
+
if os.path.exists(countpath):
|
|
168
|
+
with open(countpath) as count_f:
|
|
169
|
+
try:
|
|
170
|
+
curr_count = int(count_f.read().strip())
|
|
171
|
+
except Exception:
|
|
172
|
+
curr_count = 1
|
|
173
|
+
curr_count -= 1
|
|
174
|
+
if curr_count <= 0:
|
|
175
|
+
with suppress(FileNotFoundError, PermissionError):
|
|
176
|
+
os.remove(countpath)
|
|
177
|
+
remove_lock = True
|
|
178
|
+
else:
|
|
179
|
+
with open(countpath, "w+") as count_f:
|
|
180
|
+
logger.debug(_get_log_msg({"name": f"decrement_lock_{chunk_index}_to_{curr_count}", "ph": "B"}))
|
|
181
|
+
count_f.write(str(curr_count))
|
|
182
|
+
logger.debug(_get_log_msg({"name": f"decrement_lock_{chunk_index}_to_{curr_count}", "ph": "E"}))
|
|
183
|
+
else:
|
|
184
|
+
remove_lock = True
|
|
185
|
+
# FileLock doesn't delete its lock file on release — we clean it up manually.
|
|
186
|
+
# This must happen after release (Windows can't delete open files) and after the
|
|
187
|
+
# work is done (on Linux, deleting an in-use lock file lets other processes lock
|
|
188
|
+
# on a new inode, bypassing mutual exclusion).
|
|
189
|
+
if remove_lock:
|
|
190
|
+
with suppress(FileNotFoundError, PermissionError):
|
|
191
|
+
os.remove(lock_path)
|
|
192
|
+
return curr_count
|
|
193
|
+
|
|
122
194
|
@property
|
|
123
195
|
def skip_chunk_indexes_deletion(self) -> list[int] | None:
|
|
124
196
|
return self._skip_chunk_indexes_deletion
|
|
@@ -133,22 +205,26 @@ class ChunksConfig:
|
|
|
133
205
|
|
|
134
206
|
local_chunkpath = os.path.join(self._cache_dir, chunk_filename)
|
|
135
207
|
|
|
208
|
+
# Shared chunks are reference-counted eagerly by the reader (before any reading), so their
|
|
209
|
+
# download-time increment is skipped here to avoid double-counting. Non-shared chunks keep
|
|
210
|
+
# the original pay-as-you-download refcounting.
|
|
211
|
+
lazily_ref_counted = chunk_index not in self._shared_chunk_indexes
|
|
212
|
+
|
|
136
213
|
if os.path.exists(local_chunkpath):
|
|
137
214
|
self.try_decompress(local_chunkpath)
|
|
138
215
|
|
|
139
|
-
if self._downloader is not None and not skip_lock:
|
|
216
|
+
if self._downloader is not None and not skip_lock and lazily_ref_counted:
|
|
140
217
|
# We don't want to redownload the base, but we should mark
|
|
141
218
|
# it as having been requested by something
|
|
142
219
|
self._downloader._increment_local_lock(
|
|
143
220
|
local_chunkpath.replace(f".{self._compressor_name}", ""), chunk_index
|
|
144
221
|
)
|
|
145
|
-
pass
|
|
146
222
|
return
|
|
147
223
|
|
|
148
224
|
if self._downloader is None:
|
|
149
225
|
return
|
|
150
226
|
|
|
151
|
-
if not skip_lock:
|
|
227
|
+
if not skip_lock and lazily_ref_counted:
|
|
152
228
|
self._downloader._increment_local_lock(
|
|
153
229
|
local_chunkpath.replace(f".{self._compressor_name}", ""), chunk_index
|
|
154
230
|
)
|
|
@@ -188,37 +264,58 @@ class ChunksConfig:
|
|
|
188
264
|
if os.path.exists(target_local_chunkpath):
|
|
189
265
|
return
|
|
190
266
|
|
|
191
|
-
#
|
|
267
|
+
# Wait until either the decompressed target appears (another worker finished) or the
|
|
268
|
+
# compressed source exists. Cloud downloaders publish the compressed path atomically, so
|
|
269
|
+
# existence of that path means the download is complete — do NOT use chunk_size (item
|
|
270
|
+
# count) as a byte threshold.
|
|
192
271
|
start_time = time()
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
filename = os.path.basename(local_chunkpath)
|
|
196
|
-
chunk_index = self._get_chunk_index_from_filename(filename)
|
|
197
|
-
chunk_bytes = self._chunks[chunk_index]["chunk_size"]
|
|
198
|
-
exists = os.path.exists(local_chunkpath) and os.stat(local_chunkpath).st_size >= chunk_bytes
|
|
199
|
-
while not exists:
|
|
272
|
+
while not os.path.exists(local_chunkpath) and not os.path.exists(target_local_chunkpath):
|
|
200
273
|
sleep(0.1)
|
|
201
|
-
# Return if the actual file exists
|
|
202
|
-
if os.path.exists(target_local_chunkpath):
|
|
203
|
-
return
|
|
204
|
-
# find the local compressed file
|
|
205
|
-
exists = os.path.exists(local_chunkpath) and os.stat(local_chunkpath).st_size >= chunk_bytes
|
|
206
|
-
|
|
207
274
|
if (time() - start_time) > _MAX_WAIT_TIME:
|
|
208
275
|
raise FileNotFoundError(f"The {local_chunkpath} hasn't been found.")
|
|
209
276
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
# delete the files only if they were downloaded
|
|
214
|
-
if self._downloader is not None:
|
|
215
|
-
with contextlib.suppress(FileNotFoundError):
|
|
216
|
-
os.remove(local_chunkpath)
|
|
217
|
-
|
|
218
|
-
data = self._compressor.decompress(data)
|
|
277
|
+
if os.path.exists(target_local_chunkpath):
|
|
278
|
+
return
|
|
219
279
|
|
|
220
|
-
|
|
221
|
-
|
|
280
|
+
decompress_lock = target_local_chunkpath + ".decompress.lock"
|
|
281
|
+
try:
|
|
282
|
+
with FileLock(decompress_lock, timeout=_MAX_WAIT_TIME):
|
|
283
|
+
if os.path.exists(target_local_chunkpath):
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
with open(local_chunkpath, "rb") as f:
|
|
287
|
+
data = f.read()
|
|
288
|
+
|
|
289
|
+
# delete the compressed file only if it was downloaded
|
|
290
|
+
if self._downloader is not None:
|
|
291
|
+
with contextlib.suppress(FileNotFoundError):
|
|
292
|
+
os.remove(local_chunkpath)
|
|
293
|
+
|
|
294
|
+
data = self._compressor.decompress(data)
|
|
295
|
+
|
|
296
|
+
assert self._chunks is not None
|
|
297
|
+
filename = os.path.basename(local_chunkpath)
|
|
298
|
+
chunk_index = self._get_chunk_index_from_filename(filename)
|
|
299
|
+
expected_bytes = int(self._chunks[chunk_index]["chunk_bytes"])
|
|
300
|
+
|
|
301
|
+
tmp_path = f"{target_local_chunkpath}.tmp.{os.getpid()}"
|
|
302
|
+
try:
|
|
303
|
+
with open(tmp_path, "wb") as f:
|
|
304
|
+
f.write(data)
|
|
305
|
+
if os.stat(tmp_path).st_size < expected_bytes:
|
|
306
|
+
raise OSError(
|
|
307
|
+
f"Decompressed chunk {target_local_chunkpath} is smaller than expected "
|
|
308
|
+
f"({os.stat(tmp_path).st_size} < {expected_bytes})."
|
|
309
|
+
)
|
|
310
|
+
os.replace(tmp_path, target_local_chunkpath)
|
|
311
|
+
except Exception:
|
|
312
|
+
with contextlib.suppress(FileNotFoundError, PermissionError):
|
|
313
|
+
os.remove(tmp_path)
|
|
314
|
+
raise
|
|
315
|
+
finally:
|
|
316
|
+
# FileLock leaves its lock file behind; remove after release.
|
|
317
|
+
with contextlib.suppress(Exception):
|
|
318
|
+
os.remove(decompress_lock)
|
|
222
319
|
|
|
223
320
|
@property
|
|
224
321
|
def intervals(self) -> list[Interval]:
|
|
@@ -287,7 +384,17 @@ class ChunksConfig:
|
|
|
287
384
|
)
|
|
288
385
|
|
|
289
386
|
def __getitem__(self, index: ChunkedIndex) -> tuple[str, int, int]:
|
|
290
|
-
"""Find the associated chunk metadata.
|
|
387
|
+
"""Find the associated chunk metadata.
|
|
388
|
+
|
|
389
|
+
This is called once per item on the read hot path, but its result depends only on
|
|
390
|
+
``index.chunk_index`` (the local path, the chunk's begin offset and its byte size are all
|
|
391
|
+
fixed once the config is loaded). The per-chunk tuple is therefore memoized to avoid
|
|
392
|
+
rebuilding the path (``os.path.join`` + decompression-suffix stripping) on every item.
|
|
393
|
+
"""
|
|
394
|
+
cached = self._chunk_meta_cache.get(index.chunk_index)
|
|
395
|
+
if cached is not None:
|
|
396
|
+
return cached
|
|
397
|
+
|
|
291
398
|
assert self._chunks is not None
|
|
292
399
|
chunk = self._chunks[index.chunk_index]
|
|
293
400
|
|
|
@@ -300,7 +407,9 @@ class ChunksConfig:
|
|
|
300
407
|
|
|
301
408
|
filesize_bytes = chunk["chunk_bytes"]
|
|
302
409
|
|
|
303
|
-
|
|
410
|
+
meta = (local_chunkpath, begin, filesize_bytes)
|
|
411
|
+
self._chunk_meta_cache[index.chunk_index] = meta
|
|
412
|
+
return meta
|
|
304
413
|
|
|
305
414
|
def download_filepath(self, chunk_index: int) -> str:
|
|
306
415
|
"""The raw on-disk path that the chunk is downloaded to before any decompression."""
|
|
@@ -41,6 +41,7 @@ from litdata.streaming.combined import CombinedStreamingDataset
|
|
|
41
41
|
from litdata.streaming.dataset import StreamingDataset
|
|
42
42
|
from litdata.streaming.parallel import ParallelStreamingDataset
|
|
43
43
|
from litdata.streaming.sampler import CacheBatchSampler
|
|
44
|
+
from litdata.streaming.timing import StreamingTimingStats
|
|
44
45
|
from litdata.utilities._pytree import tree_flatten
|
|
45
46
|
from litdata.utilities.base import (
|
|
46
47
|
__NUM_CYCLES_KEY__,
|
|
@@ -674,12 +675,13 @@ class StreamingDataLoader(DataLoader):
|
|
|
674
675
|
self._worker_idx_iter: Any | None = None
|
|
675
676
|
self._latest_worker_idx = 0
|
|
676
677
|
self.restore = False
|
|
678
|
+
self._prefetch_factor = (2 if num_workers > 0 else None) if prefetch_factor is None else prefetch_factor
|
|
677
679
|
super().__init__(
|
|
678
680
|
dataset,
|
|
679
681
|
*args,
|
|
680
682
|
batch_size=batch_size,
|
|
681
683
|
num_workers=num_workers,
|
|
682
|
-
prefetch_factor=
|
|
684
|
+
prefetch_factor=self._prefetch_factor,
|
|
683
685
|
collate_fn=collate_fn,
|
|
684
686
|
**kwargs,
|
|
685
687
|
) # type: ignore
|
|
@@ -717,9 +719,12 @@ class StreamingDataLoader(DataLoader):
|
|
|
717
719
|
|
|
718
720
|
if isinstance(self.dataset, StreamingDataset):
|
|
719
721
|
assert self.batch_size
|
|
722
|
+
timing = StreamingTimingStats.instance()
|
|
720
723
|
for batch in super().__iter__():
|
|
724
|
+
t0 = timing.start()
|
|
721
725
|
self._latest_worker_idx = next(self._worker_idx_iter) # type: ignore
|
|
722
726
|
self._num_samples_yielded_streaming += self.batch_size
|
|
727
|
+
timing.record("dataloader_yield_s", t0)
|
|
723
728
|
yield batch
|
|
724
729
|
else:
|
|
725
730
|
self.dataset._set_use_streaming_dataloader(True)
|
|
@@ -744,6 +749,13 @@ class StreamingDataLoader(DataLoader):
|
|
|
744
749
|
else:
|
|
745
750
|
yield batch
|
|
746
751
|
|
|
752
|
+
# NOTE: `restore` is intentionally *not* cleared in a `finally` block here. Breaking out of
|
|
753
|
+
# this generator early (or letting it get garbage-collected, which throws `GeneratorExit` at
|
|
754
|
+
# the last `yield`) must leave `restore` untouched: callers that explicitly resumed from a
|
|
755
|
+
# checkpoint (`load_state_dict`) rely on `restore` staying `True` across such early exits so
|
|
756
|
+
# that the *next* `__iter__` call keeps skipping `reset_state_dict()` and replays from the
|
|
757
|
+
# loaded state (see `_StreamingMultiProcessingDataLoaderIter._try_put_index`). `restore` is
|
|
758
|
+
# only toggled back to `False` here, once the loop above completes a full epoch normally.
|
|
747
759
|
logger.debug(_get_log_msg({"name": "iterating_dataloader", "ph": "E"}))
|
|
748
760
|
self.restore = False
|
|
749
761
|
|