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.
Files changed (75) hide show
  1. {litdata-0.2.64/src/litdata.egg-info → litdata-0.2.65}/PKG-INFO +1 -1
  2. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/__about__.py +1 -1
  3. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/constants.py +2 -0
  4. litdata-0.2.65/src/litdata/streaming/async_prefetch.py +256 -0
  5. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/config.py +139 -30
  6. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/dataloader.py +13 -1
  7. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/dataset.py +46 -40
  8. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/downloader.py +149 -27
  9. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/item_loader.py +296 -55
  10. litdata-0.2.65/src/litdata/streaming/reader.py +1090 -0
  11. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/serializers.py +17 -6
  12. litdata-0.2.65/src/litdata/streaming/timing.py +84 -0
  13. {litdata-0.2.64 → litdata-0.2.65/src/litdata.egg-info}/PKG-INFO +1 -1
  14. {litdata-0.2.64 → litdata-0.2.65}/src/litdata.egg-info/SOURCES.txt +2 -0
  15. litdata-0.2.64/src/litdata/streaming/reader.py +0 -643
  16. {litdata-0.2.64 → litdata-0.2.65}/CONTRIBUTING.md +0 -0
  17. {litdata-0.2.64 → litdata-0.2.65}/LICENSE +0 -0
  18. {litdata-0.2.64 → litdata-0.2.65}/MANIFEST.in +0 -0
  19. {litdata-0.2.64 → litdata-0.2.65}/README.md +0 -0
  20. {litdata-0.2.64 → litdata-0.2.65}/requirements.txt +0 -0
  21. {litdata-0.2.64 → litdata-0.2.65}/setup.cfg +0 -0
  22. {litdata-0.2.64 → litdata-0.2.65}/setup.py +0 -0
  23. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/__init__.py +0 -0
  24. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/__main__.py +0 -0
  25. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/cli/__init__.py +0 -0
  26. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/cli/commands.py +0 -0
  27. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/cli/handler/__init__.py +0 -0
  28. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/cli/handler/cache.py +0 -0
  29. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/cli/handler/optimize.py +0 -0
  30. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/cli/parser.py +0 -0
  31. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/debugger.py +0 -0
  32. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/helpers.py +0 -0
  33. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/imports.py +0 -0
  34. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/processing/__init__.py +0 -0
  35. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/processing/data_processor.py +0 -0
  36. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/processing/functions.py +0 -0
  37. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/processing/readers.py +0 -0
  38. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/processing/utilities.py +0 -0
  39. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/raw/__init__.py +0 -0
  40. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/raw/dataset.py +0 -0
  41. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/raw/indexer.py +0 -0
  42. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/raw/types.py +0 -0
  43. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/requirements.py +0 -0
  44. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/__init__.py +0 -0
  45. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/cache.py +0 -0
  46. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/client.py +0 -0
  47. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/combined.py +0 -0
  48. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/compression.py +0 -0
  49. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/fs_provider.py +0 -0
  50. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/parallel.py +0 -0
  51. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/resolver.py +0 -0
  52. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/sampler.py +0 -0
  53. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/shuffle.py +0 -0
  54. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/streaming/writer.py +0 -0
  55. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/__init__.py +0 -0
  56. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/_pytree.py +0 -0
  57. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/base.py +0 -0
  58. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/breakpoint.py +0 -0
  59. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/broadcast.py +0 -0
  60. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/dataset_utilities.py +0 -0
  61. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/encryption.py +0 -0
  62. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/env.py +0 -0
  63. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/format.py +0 -0
  64. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/hf_dataset.py +0 -0
  65. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/packing.py +0 -0
  66. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/parquet.py +0 -0
  67. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/shuffle.py +0 -0
  68. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/subsample.py +0 -0
  69. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/torch_utils.py +0 -0
  70. {litdata-0.2.64 → litdata-0.2.65}/src/litdata/utilities/train_test_split.py +0 -0
  71. {litdata-0.2.64 → litdata-0.2.65}/src/litdata.egg-info/dependency_links.txt +0 -0
  72. {litdata-0.2.64 → litdata-0.2.65}/src/litdata.egg-info/entry_points.txt +0 -0
  73. {litdata-0.2.64 → litdata-0.2.65}/src/litdata.egg-info/not-zip-safe +0 -0
  74. {litdata-0.2.64 → litdata-0.2.65}/src/litdata.egg-info/requires.txt +0 -0
  75. {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.64
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
@@ -14,7 +14,7 @@
14
14
 
15
15
  import time
16
16
 
17
- __version__ = "0.2.64"
17
+ __version__ = "0.2.65"
18
18
  __author__ = "Lightning AI et al."
19
19
  __author_email__ = "pytorch@lightning.ai"
20
20
  __license__ = "Apache-2.0"
@@ -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
- # Ensure that the compressed file exists and is fully downloaded
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
- assert self._chunks is not None
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
- with open(local_chunkpath, "rb") as f:
211
- data = f.read()
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
- with open(target_local_chunkpath, "wb") as f:
221
- f.write(data)
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
- return local_chunkpath, begin, filesize_bytes
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=(2 if num_workers > 0 else None) if prefetch_factor is None else 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