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 +43 -0
- efficio/_planned.py +16 -0
- efficio/adapters/__init__.py +1 -0
- efficio/adapters/hf_trainer.py +8 -0
- efficio/adapters/lightning.py +8 -0
- efficio/budget/__init__.py +2 -0
- efficio/budget/planner.py +9 -0
- efficio/budget/shutdown.py +8 -0
- efficio/checkpoint/__init__.py +5 -0
- efficio/checkpoint/light.py +9 -0
- efficio/checkpoint/manager.py +96 -0
- efficio/checkpoint/storage.py +9 -0
- efficio/data/__init__.py +5 -0
- efficio/data/image_cache.py +386 -0
- efficio/env/__init__.py +6 -0
- efficio/env/detect.py +35 -0
- efficio/env/hardware.py +39 -0
- efficio/interfaces.py +88 -0
- efficio/preflight.py +9 -0
- efficio/report/__init__.py +1 -0
- efficio/report/notify.py +8 -0
- efficio/report/profiler.py +161 -0
- efficio/run.py +375 -0
- efficio/speed/__init__.py +1 -0
- efficio/speed/amp.py +8 -0
- efficio/speed/batch_size.py +8 -0
- efficio/speed/cache.py +8 -0
- efficio/speed/dataloader.py +129 -0
- efficio/speed/gpu_augment.py +259 -0
- efficio/speed/gpu_data.py +154 -0
- efficio/speed/memory_format.py +25 -0
- efficio/speed/multigpu.py +8 -0
- efficio-0.1.0.dist-info/METADATA +225 -0
- efficio-0.1.0.dist-info/RECORD +37 -0
- efficio-0.1.0.dist-info/WHEEL +5 -0
- efficio-0.1.0.dist-info/licenses/LICENSE +21 -0
- efficio-0.1.0.dist-info/top_level.txt +1 -0
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,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,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__)
|
efficio/data/__init__.py
ADDED
|
@@ -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
|
efficio/env/__init__.py
ADDED
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")
|