efficio 0.1.0__py3-none-any.whl

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.
efficio/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """Efficio: get more training out of every GPU hour on Colab, Kaggle and local GPUs.
2
+
3
+ import efficio
4
+ run = efficio.Run(budget="2h")
5
+ model, optimizer = run.prepare(model, optimizer)
6
+ for x, y in run.loop(loader, epochs=20):
7
+ with run.autocast():
8
+ loss = loss_fn(model(x), y)
9
+ run.backward(loss)
10
+ run.step()
11
+ run.report()
12
+ """
13
+
14
+ from importlib.metadata import PackageNotFoundError
15
+ from importlib.metadata import version as _version
16
+
17
+ from .data import CachedImageDataset, ImageCache
18
+ from .env import detect_hardware, detect_platform
19
+ from .interfaces import Budget, HardwareInfo, TrainState
20
+ from .run import Run
21
+ from .speed import gpu_augment
22
+ from .speed.gpu_augment import on_inputs
23
+ from .speed.gpu_data import GPULoader
24
+
25
+ try:
26
+ __version__ = _version("efficio") # the version in pyproject.toml, once installed
27
+ except PackageNotFoundError: # running from a source tree that isn't installed
28
+ __version__ = "0+unknown"
29
+
30
+ __all__ = [
31
+ "Run",
32
+ "GPULoader",
33
+ "ImageCache",
34
+ "CachedImageDataset",
35
+ "on_inputs",
36
+ "gpu_augment",
37
+ "detect_hardware",
38
+ "detect_platform",
39
+ "Budget",
40
+ "HardwareInfo",
41
+ "TrainState",
42
+ "__version__",
43
+ ]
efficio/_planned.py ADDED
@@ -0,0 +1,16 @@
1
+ """Support for modules whose features are planned but not released yet.
2
+
3
+ A planned module sets `__getattr__ = planned(__name__)`. Looking up any public name in it
4
+ raises NotImplementedError, so nothing in it can be mistaken for a working feature.
5
+ """
6
+
7
+ MESSAGE = "planned for a future release"
8
+
9
+
10
+ def planned(module_name):
11
+ """Return a module-level __getattr__ that rejects every public name."""
12
+ def __getattr__(name):
13
+ if name.startswith("__"): # let Python and tools probe dunder names safely
14
+ raise AttributeError(f"module {module_name!r} has no attribute {name!r}")
15
+ raise NotImplementedError(f"{module_name}.{name}: {MESSAGE}")
16
+ return __getattr__
@@ -0,0 +1 @@
1
+ """Planned for a future release: adapters for the Hugging Face Trainer and PyTorch Lightning."""
@@ -0,0 +1,8 @@
1
+ """Planned for a future release: a callback for the Hugging Face Trainer.
2
+
3
+ Nothing in this module is usable yet; any name looked up here raises NotImplementedError.
4
+ """
5
+
6
+ from .._planned import planned
7
+
8
+ __getattr__ = planned(__name__)
@@ -0,0 +1,8 @@
1
+ """Planned for a future release: a callback for PyTorch Lightning.
2
+
3
+ Nothing in this module is usable yet; any name looked up here raises NotImplementedError.
4
+ """
5
+
6
+ from .._planned import planned
7
+
8
+ __getattr__ = planned(__name__)
@@ -0,0 +1,2 @@
1
+ """Time budget features. The budget stop itself is part of efficio.Run; the planner and
2
+ auto-shutdown here are planned for a future release."""
@@ -0,0 +1,9 @@
1
+ """Planned for a future release: a budget planner that times the first steps and fits the number
2
+ of epochs and the learning-rate schedule to the time budget.
3
+
4
+ Nothing in this module is usable yet; any name looked up here raises NotImplementedError.
5
+ """
6
+
7
+ from .._planned import planned
8
+
9
+ __getattr__ = planned(__name__)
@@ -0,0 +1,8 @@
1
+ """Planned for a future release: releasing the runtime when training ends (for example on Colab).
2
+
3
+ Nothing in this module is usable yet; any name looked up here raises NotImplementedError.
4
+ """
5
+
6
+ from .._planned import planned
7
+
8
+ __getattr__ = planned(__name__)
@@ -0,0 +1,5 @@
1
+ """Atomic checkpoint save and exact resume."""
2
+
3
+ from .manager import CheckpointManager
4
+
5
+ __all__ = ["CheckpointManager"]
@@ -0,0 +1,9 @@
1
+ """Planned for a future release: light checkpoints (trainable parameters only, fp16 weights,
2
+ 8-bit optimizer state).
3
+
4
+ Nothing in this module is usable yet; any name looked up here raises NotImplementedError.
5
+ """
6
+
7
+ from .._planned import planned
8
+
9
+ __getattr__ = planned(__name__)
@@ -0,0 +1,96 @@
1
+ """Save and resume training safely.
2
+
3
+ Local disk, "exact" mode: everything needed to continue a run exactly where it stopped.
4
+ """
5
+
6
+ import glob
7
+ import os
8
+ import random
9
+
10
+ import torch
11
+
12
+ try:
13
+ import numpy as np
14
+ except ImportError: # numpy is optional
15
+ np = None
16
+
17
+
18
+ def _rng_state():
19
+ state = {"python": random.getstate(), "torch": torch.get_rng_state()}
20
+ if np is not None:
21
+ state["numpy"] = np.random.get_state()
22
+ if torch.cuda.is_available():
23
+ state["cuda"] = torch.cuda.get_rng_state_all()
24
+ return state
25
+
26
+
27
+ def _set_rng_state(state):
28
+ # RNG states must be CPU ByteTensors, even when restoring CUDA generators.
29
+ random.setstate(state["python"])
30
+ torch.set_rng_state(state["torch"].cpu())
31
+ if np is not None and "numpy" in state:
32
+ np.random.set_state(state["numpy"])
33
+ if torch.cuda.is_available() and "cuda" in state:
34
+ torch.cuda.set_rng_state_all([s.cpu() for s in state["cuda"]])
35
+
36
+
37
+ class CheckpointManager:
38
+ """Saves and loads full training checkpoints in one folder, keeping the newest
39
+ `keep_last` files. Every save is atomic."""
40
+
41
+ def __init__(self, directory, keep_last=2, mode="exact"):
42
+ self.directory = directory
43
+ self.keep_last = keep_last
44
+ self.mode = mode
45
+ self.extra = {} # the "extra" dict of the last loaded checkpoint
46
+ os.makedirs(directory, exist_ok=True)
47
+
48
+ def _path(self, step):
49
+ return os.path.join(self.directory, f"ckpt_step{step:09d}.pt")
50
+
51
+ def save(self, train_state, model, optimizer, scheduler=None, scaler=None, extra=None):
52
+ """`extra`: a small dict of other resume state (e.g. the data shuffle seed)."""
53
+ payload = {
54
+ "efficio_version": 1,
55
+ "mode": self.mode,
56
+ "train_state": train_state.to_dict(),
57
+ "model": model.state_dict(),
58
+ "optimizer": optimizer.state_dict(),
59
+ "scheduler": scheduler.state_dict() if scheduler is not None else None,
60
+ "scaler": scaler.state_dict() if scaler is not None else None,
61
+ "rng": _rng_state(),
62
+ "extra": dict(extra or {}),
63
+ }
64
+ final = self._path(train_state.step)
65
+ tmp = final + ".tmp"
66
+ torch.save(payload, tmp)
67
+ os.replace(tmp, final) # atomic: a crash mid-save never corrupts the last good file
68
+ self._cleanup()
69
+ return final
70
+
71
+ def latest(self):
72
+ """Path of the newest checkpoint in the folder, or None."""
73
+ files = sorted(glob.glob(os.path.join(self.directory, "ckpt_step*.pt")))
74
+ return files[-1] if files else None
75
+
76
+ def load(self, model, optimizer, scheduler=None, scaler=None, path=None, map_location="cpu"):
77
+ """Restore everything. Returns the saved train_state dict, or None if nothing to resume."""
78
+ path = path or self.latest()
79
+ if path is None:
80
+ return None
81
+ # Our own file holds RNG states and plain dicts, so the weights_only loader can't read it.
82
+ payload = torch.load(path, map_location=map_location, weights_only=False)
83
+ model.load_state_dict(payload["model"])
84
+ optimizer.load_state_dict(payload["optimizer"])
85
+ if scheduler is not None and payload["scheduler"] is not None:
86
+ scheduler.load_state_dict(payload["scheduler"])
87
+ if scaler is not None and payload["scaler"] is not None:
88
+ scaler.load_state_dict(payload["scaler"])
89
+ _set_rng_state(payload["rng"])
90
+ self.extra = payload.get("extra") or {} # older checkpoints have none
91
+ return payload["train_state"]
92
+
93
+ def _cleanup(self):
94
+ files = sorted(glob.glob(os.path.join(self.directory, "ckpt_step*.pt")))
95
+ for old in files[:-self.keep_last]:
96
+ os.remove(old)
@@ -0,0 +1,9 @@
1
+ """Planned for a future release: storing checkpoints on the Hugging Face Hub or Google Drive, so
2
+ a run can move between platforms.
3
+
4
+ Nothing in this module is usable yet; any name looked up here raises NotImplementedError.
5
+ """
6
+
7
+ from .._planned import planned
8
+
9
+ __getattr__ = planned(__name__)
@@ -0,0 +1,5 @@
1
+ """Data preparation: preprocess a dataset once, train fast afterwards."""
2
+
3
+ from .image_cache import CachedImageDataset, ImageCache
4
+
5
+ __all__ = ["ImageCache", "CachedImageDataset"]
@@ -0,0 +1,386 @@
1
+ """Decode and resize an image dataset once, then train from a fast uint8 array.
2
+
3
+ On Colab and Kaggle, 2-4 CPU cores decoding and resizing PNG/JPEG files cannot keep a GPU
4
+ busy. ImageCache.build does that work once, with a process pool, and stores the result as
5
+ one uint8 array file (N, H, W, C) next to the labels. CachedImageDataset then reads samples
6
+ straight from that file, so training does no image decoding at all. Do the float conversion
7
+ and augmentation on the GPU with run.loop(..., gpu_transform=...).
8
+
9
+ ImageCache.build(paths, labels, "/content/cache/train", size=(224, 224), grayscale=True)
10
+ loader = DataLoader(CachedImageDataset("/content/cache/train"), batch_size=64, shuffle=True)
11
+
12
+ The build is resumable: it writes the array in chunks and records finished chunks, so an
13
+ interrupted build (quota, disconnect) continues where it stopped. Images are resized to
14
+ `size` exactly (no aspect-preserving crop).
15
+
16
+ Reading and decoding overlap: a thread pool (io_threads) reads file bytes ahead while worker
17
+ processes decode. On network-backed storage such as Kaggle's /kaggle/input each file read has
18
+ high latency, so many reads in parallel are much faster than one after another.
19
+
20
+ Building the cache needs no GPU, so on Colab/Kaggle it can be done in a CPU-only session.
21
+
22
+ This module does not import torch at import time, so TensorFlow/Keras support can reuse
23
+ the cache builder.
24
+ """
25
+
26
+ import hashlib
27
+ import io
28
+ import json
29
+ import os
30
+ import time
31
+ from collections import deque
32
+ from concurrent.futures import FIRST_COMPLETED, ProcessPoolExecutor, ThreadPoolExecutor, wait
33
+
34
+ import numpy as np
35
+
36
+ from ..env.detect import detect_platform
37
+
38
+ FORMAT_VERSION = 1
39
+ IMAGES_FILE, LABELS_FILE, META_FILE, PROGRESS_FILE = (
40
+ "images.npy", "labels.npy", "meta.json", "progress.json")
41
+ DECODE_GROUP = 16 # images per decode task
42
+ READ_TIP_RATIO = 0.5 # reads below this share of decode capacity: storage is the limit
43
+ GPU_NOTE = ("Building the cache doesn't need a GPU. To save GPU quota, build it once in a "
44
+ "CPU-only session, save it (Kaggle: Save Version or a private dataset; Colab: "
45
+ "Google Drive), then train in a GPU session.")
46
+ _gpu_note_shown = False
47
+
48
+
49
+ def _read_file(path):
50
+ """Read one file's bytes. Runs in an I/O thread."""
51
+ try:
52
+ with open(path, "rb") as f:
53
+ return f.read()
54
+ except OSError as e:
55
+ raise RuntimeError(f"ImageCache: could not read image {path}: {e}") from e
56
+
57
+
58
+ def _decode_group(blobs, names, size, grayscale):
59
+ """Decode and resize images from their file bytes. Runs in a worker process.
60
+ Returns ((n, H, W, C) uint8 array, seconds spent)."""
61
+ from PIL import Image
62
+ start = time.perf_counter()
63
+ h, w = size
64
+ out = np.empty((len(blobs), h, w, 1 if grayscale else 3), dtype=np.uint8)
65
+ for i, (blob, name) in enumerate(zip(blobs, names)):
66
+ try:
67
+ with Image.open(io.BytesIO(blob)) as img:
68
+ img = img.convert("L" if grayscale else "RGB")
69
+ img = img.resize((w, h), Image.BILINEAR)
70
+ out[i] = np.asarray(img, dtype=np.uint8).reshape(h, w, -1)
71
+ except Exception as e:
72
+ raise RuntimeError(f"ImageCache: could not read image {name}: {e}") from e
73
+ return out, time.perf_counter() - start
74
+
75
+
76
+ def _cuda_available():
77
+ try:
78
+ import torch
79
+ except ImportError:
80
+ return False
81
+ return torch.cuda.is_available()
82
+
83
+
84
+ def _gpu_note():
85
+ """The GPU quota note, once per process, when building on Colab/Kaggle with a GPU."""
86
+ global _gpu_note_shown
87
+ if _gpu_note_shown or detect_platform() not in ("colab", "kaggle") or not _cuda_available():
88
+ return None
89
+ _gpu_note_shown = True
90
+ return GPU_NOTE
91
+
92
+
93
+ def _paths_hash(paths):
94
+ return hashlib.sha1("\n".join(str(p) for p in paths).encode("utf-8")).hexdigest()
95
+
96
+
97
+ def _write_json(path, data):
98
+ tmp = path + ".tmp"
99
+ with open(tmp, "w", encoding="utf-8") as f:
100
+ json.dump(data, f)
101
+ os.replace(tmp, path) # atomic: an interrupted write never leaves a broken file
102
+
103
+
104
+ def _read_json(path):
105
+ try:
106
+ with open(path, encoding="utf-8") as f:
107
+ return json.load(f)
108
+ except (OSError, ValueError):
109
+ return None
110
+
111
+
112
+ def _size(nbytes):
113
+ return f"{nbytes / 1024**3:.2f} GB" if nbytes >= 1024**3 else f"{nbytes / 1024**2:.1f} MB"
114
+
115
+
116
+ def _storage_tip(out_dir):
117
+ platform = detect_platform()
118
+ if platform == "colab":
119
+ if os.path.abspath(out_dir).startswith("/content/drive"):
120
+ return "It is on Google Drive, so it survives the session."
121
+ return ("To keep it for later sessions, copy the folder to Google Drive (for example "
122
+ "!cp -r <cache> /content/drive/MyDrive/). Copying one finished folder is much "
123
+ "faster than building on Drive directly.")
124
+ if platform == "kaggle":
125
+ return ("To keep it for later sessions, save it as notebook output (it must be under "
126
+ "/kaggle/working, then Save Version) or upload it as a private dataset, and "
127
+ "attach that in later notebooks.")
128
+ return "Keep it on a fast local disk (an SSD) for the best read speed."
129
+
130
+
131
+ class ImageCache:
132
+ """A finished cache folder. Create one with ImageCache.build(...)."""
133
+
134
+ def __init__(self, out_dir):
135
+ self.out_dir = str(out_dir)
136
+ self.build_stats = None
137
+ self.meta = _read_json(os.path.join(self.out_dir, META_FILE))
138
+ if not self.meta or not self.meta.get("complete"):
139
+ raise FileNotFoundError(f"No finished image cache in {self.out_dir}. "
140
+ "Run ImageCache.build(...) first.")
141
+
142
+ def __len__(self):
143
+ return self.meta["count"]
144
+
145
+ @property
146
+ def size_on_disk(self):
147
+ """Bytes used by the cache files."""
148
+ return sum(os.path.getsize(os.path.join(self.out_dir, f))
149
+ for f in (IMAGES_FILE, LABELS_FILE, META_FILE))
150
+
151
+ @classmethod
152
+ def build(cls, paths, labels, out_dir, size=(224, 224), grayscale=False, workers=None,
153
+ io_threads=32, chunk_size=256, verbose=True):
154
+ """Decode and resize every image in `paths` once into `out_dir`.
155
+
156
+ labels: N ints (single label) or an (N, K) array of 0/1 floats (multi-label).
157
+ workers: processes for decoding (default: all CPU cores; 0 or 1 = no pool). With
158
+ workers > 1 on Windows/macOS, call this under `if __name__ == "__main__":`.
159
+ io_threads: threads reading files ahead of decoding. Raise it (e.g. 64) when the
160
+ progress line shows reads far below decoding (slow network storage).
161
+ Returns immediately if a finished cache with the same settings and paths exists.
162
+ The returned cache has `build_stats` (rates, None when nothing was built).
163
+ """
164
+ paths = [str(p) for p in paths]
165
+ labels = np.asarray(labels)
166
+ if len(paths) == 0 or len(labels) != len(paths):
167
+ raise ValueError(f"need the same, non-zero number of paths and labels "
168
+ f"(got {len(paths)} and {len(labels)})")
169
+ if labels.ndim == 1:
170
+ labels = labels.astype(np.int64)
171
+ elif labels.ndim == 2:
172
+ labels = labels.astype(np.float32)
173
+ else:
174
+ raise ValueError("labels must be 1-D (class ids) or 2-D (multi-hot)")
175
+ h, w = (size, size) if isinstance(size, int) else tuple(size)
176
+ channels = 1 if grayscale else 3
177
+ out_dir = str(out_dir)
178
+ os.makedirs(out_dir, exist_ok=True)
179
+ meta = {"version": FORMAT_VERSION, "size": [h, w], "channels": channels,
180
+ "count": len(paths), "label_shape": list(labels.shape[1:]),
181
+ "label_dtype": str(labels.dtype), "paths_sha1": _paths_hash(paths),
182
+ "chunk_size": chunk_size}
183
+ meta_path = os.path.join(out_dir, META_FILE)
184
+ progress_path = os.path.join(out_dir, PROGRESS_FILE)
185
+ images_path = os.path.join(out_dir, IMAGES_FILE)
186
+
187
+ old = _read_json(meta_path)
188
+ same = old is not None and {k: old.get(k) for k in meta} == meta
189
+ if same and old.get("complete"):
190
+ cache = cls(out_dir)
191
+ if verbose:
192
+ print(f"[efficio] image cache: using the finished cache in {out_dir} "
193
+ f"({len(cache)} images, {_size(cache.size_on_disk)})")
194
+ return cache
195
+
196
+ n_chunks = (len(paths) + chunk_size - 1) // chunk_size
197
+ done = set((_read_json(progress_path) or {}).get("done", [])) if same else set()
198
+ if same and os.path.exists(images_path):
199
+ images = np.load(images_path, mmap_mode="r+")
200
+ else:
201
+ if old is not None and verbose:
202
+ print(f"[efficio] image cache: settings or paths changed, rebuilding {out_dir}")
203
+ done = set()
204
+ images = np.lib.format.open_memmap(images_path, mode="w+", dtype=np.uint8,
205
+ shape=(len(paths), h, w, channels))
206
+ np.save(os.path.join(out_dir, LABELS_FILE), labels)
207
+ _write_json(progress_path, {"done": []})
208
+ _write_json(meta_path, {**meta, "complete": False})
209
+
210
+ todo = [c for c in range(n_chunks) if c not in done]
211
+ if workers is None:
212
+ workers = os.cpu_count() or 1
213
+ io_threads = max(1, int(io_threads))
214
+ if verbose:
215
+ note = _gpu_note()
216
+ if note:
217
+ print(f"[efficio] note: {note}")
218
+ resumed = f", resuming ({len(done)}/{n_chunks} chunks already done)" if done else ""
219
+ print(f"[efficio] image cache: decoding {len(paths)} images to {h}x{w}x{channels} "
220
+ f"in {out_dir}{resumed}")
221
+
222
+ # Work in small groups of images inside each unfinished chunk
223
+ limit = 4 * io_threads # files read but not yet written: bounds memory
224
+ group = min(DECODE_GROUP, limit)
225
+ groups, left_in_chunk = deque(), {}
226
+ for c in todo:
227
+ first, end = c * chunk_size, min((c + 1) * chunk_size, len(paths))
228
+ left_in_chunk[c] = end - first
229
+ groups.extend(list(range(i, min(i + group, end))) for i in range(first, end, group))
230
+
231
+ def finish_chunk(c):
232
+ images.flush() # data on disk before it counts
233
+ done.add(c)
234
+ _write_json(progress_path, {"done": sorted(done)})
235
+
236
+ progress = _Progress(sum(left_in_chunk.values()), verbose, io_threads,
237
+ workers if workers > 1 else 1)
238
+ readers = ThreadPoolExecutor(max_workers=io_threads)
239
+ decoders = ProcessPoolExecutor(max_workers=workers) if workers > 1 else None
240
+ try:
241
+ reading, decoding, in_flight = [], {}, 0
242
+
243
+ def write(g, arr, seconds):
244
+ nonlocal in_flight
245
+ images[g[0]:g[-1] + 1] = arr # each group has its own rows: any order
246
+ in_flight -= len(g)
247
+ c = g[0] // chunk_size
248
+ left_in_chunk[c] -= len(g)
249
+ if left_in_chunk[c] == 0: # a chunk counts once all of it is written
250
+ finish_chunk(c)
251
+ progress.decoded(len(g), seconds)
252
+
253
+ while groups or reading or decoding:
254
+ # 1. Read ahead (in file order), as long as the in-flight limit allows
255
+ while groups and in_flight + len(groups[0]) <= limit:
256
+ g = groups.popleft()
257
+ reading.append((g, [readers.submit(_read_file, paths[i]) for i in g]))
258
+ in_flight += len(g)
259
+ progress.max_in_flight = max(progress.max_in_flight, in_flight)
260
+ moved = False
261
+ # 2. Any group whose files are all read goes to decoding, so one slow file
262
+ # only holds back its own group
263
+ for item in [r for r in reading if all(f.done() for f in r[1])]:
264
+ reading.remove(item)
265
+ g, futures = item
266
+ blobs = [f.result() for f in futures] # raises read errors
267
+ progress.read(len(g))
268
+ args = (blobs, [paths[i] for i in g], (h, w), grayscale)
269
+ if decoders is None:
270
+ write(g, *_decode_group(*args)) # nothing waits unsaved
271
+ else:
272
+ decoding[decoders.submit(_decode_group, *args)] = g
273
+ moved = True
274
+ # 3. Write every finished group, in whatever order they finish
275
+ for future in [f for f in decoding if f.done()]:
276
+ write(decoding.pop(future), *future.result())
277
+ moved = True
278
+ if not moved:
279
+ pending = [f for _, futures in reading for f in futures if not f.done()]
280
+ wait(pending + list(decoding), return_when=FIRST_COMPLETED)
281
+ finally:
282
+ # On an interrupt, drop queued work instead of finishing it first
283
+ readers.shutdown(wait=True, cancel_futures=True)
284
+ if decoders is not None:
285
+ decoders.shutdown(wait=True, cancel_futures=True)
286
+ progress.close()
287
+ images = None # close the memory-mapped file
288
+ _write_json(meta_path, {**meta, "complete": True})
289
+ cache = cls(out_dir)
290
+ cache.build_stats = progress.stats()
291
+ if verbose:
292
+ print(f"[efficio] image cache: done, {_size(cache.size_on_disk)} on disk in "
293
+ f"{out_dir}. {_storage_tip(out_dir)}")
294
+ return cache
295
+
296
+
297
+ class _Progress:
298
+ """One progress line: overall images/s, file reads/s, decode capacity and time left.
299
+ Prints a one-time tip when reading files is the bottleneck."""
300
+
301
+ def __init__(self, total, verbose, io_threads, decode_workers):
302
+ self.total, self.verbose = total, verbose
303
+ self.io_threads, self.decode_workers = io_threads, decode_workers
304
+ self.done = self.files_read = 0
305
+ self.decode_s, self.max_in_flight = 0.0, 0
306
+ self.start, self.last, self.tip_shown = time.time(), 0.0, False
307
+
308
+ def read(self, n):
309
+ self.files_read += n
310
+
311
+ def _rates(self):
312
+ elapsed = max(time.time() - self.start, 1e-9)
313
+ # Decode capacity: images per second of decode work, times the parallel workers
314
+ capacity = self.done / self.decode_s * self.decode_workers if self.decode_s else 0.0
315
+ return elapsed, self.done / elapsed, self.files_read / elapsed, capacity
316
+
317
+ def decoded(self, n, seconds):
318
+ self.done += n
319
+ self.decode_s += seconds
320
+ now = time.time()
321
+ if not self.verbose or (now - self.last < 1.0 and self.done < self.total):
322
+ return
323
+ self.last = now
324
+ elapsed, rate, reads, capacity = self._rates()
325
+ left = (self.total - self.done) / rate if rate > 0 else 0
326
+ print(f"\r[efficio] image cache: {self.done}/{self.total} images, {rate:.0f} img/s "
327
+ f"(read {reads:.0f} files/s, decode up to {capacity:.0f} img/s), "
328
+ f"~{left / 60:.1f} min left ", end="", flush=True)
329
+ if (not self.tip_shown and elapsed >= 10 and self.done >= 50
330
+ and reads < READ_TIP_RATIO * capacity):
331
+ self.tip_shown = True
332
+ print(f"\n[efficio] image cache: reading files ({reads:.0f}/s) is much slower than "
333
+ f"decoding ({capacity:.0f}/s), so storage is the bottleneck. Try a higher "
334
+ f"io_threads, e.g. ImageCache.build(..., io_threads={2 * self.io_threads}).")
335
+
336
+ def stats(self):
337
+ elapsed, rate, reads, capacity = self._rates()
338
+ return {"seconds": round(elapsed, 2), "images": self.done,
339
+ "images_per_s": round(rate, 1), "read_files_per_s": round(reads, 1),
340
+ "decode_capacity_per_s": round(capacity, 1), "io_threads": self.io_threads,
341
+ "decode_workers": self.decode_workers, "max_in_flight": self.max_in_flight}
342
+
343
+ def close(self):
344
+ if self.verbose and self.total:
345
+ print()
346
+
347
+
348
+ class CachedImageDataset:
349
+ """Map-style dataset over a finished ImageCache. Returns (uint8 tensor (C, H, W), label):
350
+ an int64 class id, or a float32 multi-hot vector. No decoding happens here, so convert and
351
+ augment on the GPU (run.loop(..., gpu_transform=...)).
352
+
353
+ The arrays are opened lazily in each process, so DataLoader workers do not copy them."""
354
+
355
+ def __init__(self, out_dir):
356
+ self.cache = ImageCache(out_dir) # raises if the cache is not finished
357
+ self.out_dir = self.cache.out_dir
358
+ self._images = self._labels = None
359
+
360
+ def _open(self):
361
+ if self._images is None:
362
+ self._images = np.load(os.path.join(self.out_dir, IMAGES_FILE), mmap_mode="r")
363
+ self._labels = np.load(os.path.join(self.out_dir, LABELS_FILE), mmap_mode="r")
364
+
365
+ def __len__(self):
366
+ return len(self.cache)
367
+
368
+ def __getitem__(self, i):
369
+ import torch
370
+ self._open()
371
+ # np.array copies: the memory-mapped file is read-only and must stay unchanged
372
+ image = torch.from_numpy(np.array(self._images[i].transpose(2, 0, 1)))
373
+ label = self._labels[i]
374
+ label = torch.from_numpy(np.array(label)) if label.ndim else torch.tensor(int(label))
375
+ return image, label
376
+
377
+ def __getstate__(self):
378
+ state = self.__dict__.copy()
379
+ state["_images"] = state["_labels"] = None # reopen in the worker, don't pickle data
380
+ return state
381
+
382
+ @property
383
+ def labels(self):
384
+ """All labels as a (memory-mapped) numpy array."""
385
+ self._open()
386
+ return self._labels
@@ -0,0 +1,6 @@
1
+ """Detect the platform (Colab, Kaggle or local) and the GPU."""
2
+
3
+ from .detect import default_checkpoint_dir, detect_platform
4
+ from .hardware import describe, detect_hardware
5
+
6
+ __all__ = ["detect_platform", "default_checkpoint_dir", "detect_hardware", "describe"]
efficio/env/detect.py ADDED
@@ -0,0 +1,35 @@
1
+ """Work out where we are running: Colab, Kaggle or a local machine."""
2
+
3
+ import importlib.util
4
+ import os
5
+
6
+ # Known session caps in hours (approximate; Colab free limits vary and are not guaranteed).
7
+ SESSION_LIMITS = {"colab": 12.0, "kaggle": 12.0, "local": 0.0}
8
+
9
+
10
+ def detect_platform() -> str:
11
+ """Where we are running: "colab", "kaggle" or "local"."""
12
+ if os.environ.get("KAGGLE_KERNEL_RUN_TYPE") or os.environ.get("KAGGLE_URL_BASE"):
13
+ return "kaggle"
14
+ if os.environ.get("COLAB_RELEASE_TAG") or _has_module("google.colab"):
15
+ return "colab"
16
+ return "local"
17
+
18
+
19
+ def _has_module(name):
20
+ # find_spec("google.colab") raises instead of returning None when "google" itself is missing
21
+ try:
22
+ return importlib.util.find_spec(name) is not None
23
+ except ModuleNotFoundError:
24
+ return False
25
+
26
+
27
+ def default_checkpoint_dir(platform: str) -> str:
28
+ """A sensible place to save checkpoints on each platform."""
29
+ if platform == "kaggle":
30
+ return "/kaggle/working/efficio_checkpoints" # kept in the notebook's output
31
+ if platform == "colab":
32
+ if os.path.isdir("/content/drive/MyDrive"): # Drive mounted: survives the session
33
+ return "/content/drive/MyDrive/efficio_checkpoints"
34
+ return "/content/efficio_checkpoints"
35
+ return os.path.join(os.getcwd(), "efficio_checkpoints")