learning-loop-node 0.23.0__py3-none-any.whl → 0.25.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.
- learning_loop_node/detector/outbox.py +4 -2
- learning_loop_node/enums/trainer.py +2 -0
- learning_loop_node/helpers/entrypoint.py +7 -1
- learning_loop_node/helpers/misc.py +1 -1
- learning_loop_node/trainer/batch_size.py +58 -13
- learning_loop_node/trainer/cuda.py +168 -26
- learning_loop_node/trainer/exceptions.py +12 -0
- learning_loop_node/trainer/trainer_logic_generic.py +35 -5
- learning_loop_node/trainer/trainer_node.py +9 -2
- learning_loop_node/trainer/training_slot.py +207 -0
- {learning_loop_node-0.23.0.dist-info → learning_loop_node-0.25.0.dist-info}/METADATA +28 -1
- {learning_loop_node-0.23.0.dist-info → learning_loop_node-0.25.0.dist-info}/RECORD +14 -13
- {learning_loop_node-0.23.0.dist-info → learning_loop_node-0.25.0.dist-info}/WHEEL +0 -0
- {learning_loop_node-0.23.0.dist-info → learning_loop_node-0.25.0.dist-info}/top_level.txt +0 -0
|
@@ -37,7 +37,7 @@ class Outbox():
|
|
|
37
37
|
It handles upload failures by splitting the upload into two smaller batches until the problematic image is identified - and removed.
|
|
38
38
|
Any image can be saved to the normal or the priority queue.
|
|
39
39
|
Images in the priority queue are uploaded first.
|
|
40
|
-
The
|
|
40
|
+
The normal queue is limited to MAX_OUTBOX_SIZE images (default 1000); the oldest are dropped first.
|
|
41
41
|
"""
|
|
42
42
|
|
|
43
43
|
def __init__(self) -> None:
|
|
@@ -59,7 +59,9 @@ class Outbox():
|
|
|
59
59
|
self.log.info('Outbox initialized with target_uri: %s', self.target_uri)
|
|
60
60
|
|
|
61
61
|
self.BATCH_SIZE = 20
|
|
62
|
-
self.MAX_UPLOAD_LENGTH = 1000 # only
|
|
62
|
+
self.MAX_UPLOAD_LENGTH = int(os.environ.get('MAX_OUTBOX_SIZE', '1000')) # only limits `upload_folders`
|
|
63
|
+
if self.MAX_UPLOAD_LENGTH <= 0:
|
|
64
|
+
raise ValueError(f'MAX_OUTBOX_SIZE must be positive, got {self.MAX_UPLOAD_LENGTH}')
|
|
63
65
|
self.UPLOAD_INTERVAL_S = 5
|
|
64
66
|
self.UPLOAD_TIMEOUT_S = 30
|
|
65
67
|
|
|
@@ -4,7 +4,9 @@ from enum import Enum
|
|
|
4
4
|
|
|
5
5
|
class TrainerState(str, Enum):
|
|
6
6
|
Idle = 'idle'
|
|
7
|
+
Blocked = 'blocked' # idle, but unable to take a training
|
|
7
8
|
Initialized = 'initialized'
|
|
9
|
+
WaitingForSlot = 'waiting_for_slot'
|
|
8
10
|
Preparing = 'preparing'
|
|
9
11
|
DataDownloading = 'data_downloading'
|
|
10
12
|
DataDownloaded = 'data_downloaded'
|
|
@@ -12,21 +12,27 @@ from argparse import Action, Namespace
|
|
|
12
12
|
import configargparse
|
|
13
13
|
import uvicorn
|
|
14
14
|
|
|
15
|
+
from ..trainer.batch_size import VRAM_LIMIT_GB_FLAG, VRAM_LIMIT_GB_HELP
|
|
16
|
+
|
|
15
17
|
logger = logging.getLogger(__name__)
|
|
16
18
|
|
|
17
19
|
|
|
18
|
-
def node_parser(*, description: str, legacy_env_prefix: str = ''
|
|
20
|
+
def node_parser(*, description: str, legacy_env_prefix: str = '',
|
|
21
|
+
vram_limit: bool = False) -> configargparse.ArgumentParser:
|
|
19
22
|
"""Build the parser for a node, pre-loaded with the settings every node has.
|
|
20
23
|
|
|
21
24
|
:param legacy_env_prefix: A prefix an earlier version of this node required, e.g.
|
|
22
25
|
``'MY_DETECTOR_'``. Both spellings keep working, with a warning: the prefix on the
|
|
23
26
|
current name (``MY_DETECTOR_NODE_HOST``) and the prefix on the flag it was originally
|
|
24
27
|
applied to (``MY_DETECTOR_HOST``). Leave empty for a node that never used one.
|
|
28
|
+
:param vram_limit: Add :data:`~learning_loop_node.trainer.batch_size.VRAM_LIMIT_GB_FLAG`.
|
|
25
29
|
"""
|
|
26
30
|
parser = _NodeArgumentParser(description=description, legacy_env_prefix=legacy_env_prefix)
|
|
27
31
|
parser.add_argument('--host', default='0.0.0.0', env_var='NODE_HOST',
|
|
28
32
|
help='Host interface to bind to')
|
|
29
33
|
parser.add_argument('--port', type=int, default=80, env_var='NODE_PORT', help='Port to bind to')
|
|
34
|
+
if vram_limit:
|
|
35
|
+
parser.add_argument(VRAM_LIMIT_GB_FLAG, type=float, default=0, help=VRAM_LIMIT_GB_HELP)
|
|
30
36
|
return parser
|
|
31
37
|
|
|
32
38
|
|
|
@@ -150,7 +150,7 @@ def ensure_socket_response(func):
|
|
|
150
150
|
if isinstance(value, bool):
|
|
151
151
|
return asdict(SocketResponse.from_bool(value))
|
|
152
152
|
if isinstance(value, SocketResponse):
|
|
153
|
-
return value
|
|
153
|
+
return asdict(value)
|
|
154
154
|
if (args[0] in ['connect', 'disconnect', 'connect_error']):
|
|
155
155
|
return value
|
|
156
156
|
if value is None:
|
|
@@ -10,12 +10,29 @@ https://github.com/Lightning-AI/pytorch-lightning
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import logging
|
|
13
|
-
from collections.abc import Callable
|
|
13
|
+
from collections.abc import Callable, Mapping
|
|
14
|
+
from typing import Any
|
|
14
15
|
|
|
15
16
|
from .exceptions import InsufficientMemoryError
|
|
16
17
|
|
|
17
18
|
logger = logging.getLogger(__name__)
|
|
18
19
|
|
|
20
|
+
REQUESTED_BATCH_SIZE = 'max_batch_size'
|
|
21
|
+
"""The hyperparameter every node reads its `measure_batch_size` argument out of.
|
|
22
|
+
|
|
23
|
+
0 or absent means the card decides. It is an input only: a trainer reports the size it settled on
|
|
24
|
+
under a different key, conventionally ``batch_size``, and never writes it back here.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
VRAM_LIMIT_GB_FLAG = '--vram-limit-gb'
|
|
28
|
+
"""The flag a trainer takes its GPU budget from; ``VRAM_LIMIT_GB`` follows from the name."""
|
|
29
|
+
|
|
30
|
+
VRAM_LIMIT_GB_HELP = ('Gigabytes of GPU memory a training may use. The batch size is probed against this limit '
|
|
31
|
+
'instead of the whole card, so a lower limit yields a smaller batch size rather than an '
|
|
32
|
+
'out-of-memory error. Use it to share a GPU or to keep headroom against fragmentation. '
|
|
33
|
+
"The limit is relative to the card's total memory, not to what is currently free. "
|
|
34
|
+
'0 (default) means no limit.')
|
|
35
|
+
|
|
19
36
|
MAX_BATCH_SIZE = 1024
|
|
20
37
|
"""Where a search stops when its caller sets no bound of its own."""
|
|
21
38
|
|
|
@@ -26,23 +43,51 @@ MIN_TRAIN_STEPS_PER_EPOCH = 8
|
|
|
26
43
|
"""Fewest optimizer steps an epoch must have; :func:`dataset_limit` is derived from it."""
|
|
27
44
|
|
|
28
45
|
|
|
29
|
-
def find_batch_size(fits: Callable[[int], bool], *, limit: int
|
|
30
|
-
|
|
46
|
+
def find_batch_size(fits: Callable[[int], bool], *, limit: int, minimum: int = 1,
|
|
47
|
+
candidate: int = 0) -> int:
|
|
48
|
+
"""Return the largest batch size that fits, never exceeding ``limit``.
|
|
49
|
+
|
|
50
|
+
Powers of two, plus ``minimum`` and ``candidate``, the two sizes outside that set this will
|
|
51
|
+
return, and only because somebody named them and they then measured.
|
|
31
52
|
|
|
32
53
|
:param fits: Runs a representative probe; ``False`` on out-of-memory.
|
|
33
|
-
:
|
|
54
|
+
:param limit: Upper bound; the doubling stops at the largest power of two within it.
|
|
55
|
+
:param minimum: Smallest size to try, as named; the doubling continues from the next power of
|
|
56
|
+
two above it. Raise it above one for a step that cannot run on a single sample at all:
|
|
57
|
+
BatchNorm over a 1x1 feature map, a validation pass that halves the batch.
|
|
58
|
+
:param candidate: An exact size, tried once the doubling has reached its ceiling, so a size
|
|
59
|
+
that was asked for is used as asked for rather than rounded down. Ignored unless it lies
|
|
60
|
+
between that ceiling and ``limit``; a bound nobody named — one derived from the dataset,
|
|
61
|
+
say — must not be passed here.
|
|
62
|
+
:raises InsufficientMemoryError: If not even ``minimum`` fits.
|
|
34
63
|
"""
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
64
|
+
minimum = max(1, minimum)
|
|
65
|
+
bound = max(limit, minimum)
|
|
66
|
+
ceiling = max(smaller_pot(bound), minimum)
|
|
67
|
+
|
|
68
|
+
if not fits(minimum):
|
|
69
|
+
raise InsufficientMemoryError(f'batch size {minimum} does not fit in memory')
|
|
38
70
|
|
|
39
|
-
size =
|
|
40
|
-
while size <
|
|
41
|
-
size
|
|
71
|
+
size = minimum
|
|
72
|
+
while size < ceiling and fits(smaller_pot(size) * 2):
|
|
73
|
+
size = smaller_pot(size) * 2
|
|
74
|
+
|
|
75
|
+
if size == ceiling and ceiling < candidate <= bound and fits(candidate):
|
|
76
|
+
size = candidate
|
|
42
77
|
|
|
43
78
|
return size
|
|
44
79
|
|
|
45
80
|
|
|
81
|
+
def requested_batch_size(hyperparameters: Mapping[str, Any]) -> int:
|
|
82
|
+
"""The bound a training asked for, read out of the hyperparameters the loop sent.
|
|
83
|
+
|
|
84
|
+
An unfilled field arrives as absent, ``None`` or ``''``, and all three mean no bound.
|
|
85
|
+
|
|
86
|
+
:raises ValueError: If the value is there but is not a number.
|
|
87
|
+
"""
|
|
88
|
+
return int(hyperparameters.get(REQUESTED_BATCH_SIZE, 0) or 0)
|
|
89
|
+
|
|
90
|
+
|
|
46
91
|
def dataset_limit(sample_count: int) -> int:
|
|
47
92
|
"""The batch size ceiling that still leaves ``MIN_TRAIN_STEPS_PER_EPOCH`` steps per epoch."""
|
|
48
93
|
if sample_count < 1:
|
|
@@ -50,9 +95,9 @@ def dataset_limit(sample_count: int) -> int:
|
|
|
50
95
|
return max(1, sample_count // MIN_TRAIN_STEPS_PER_EPOCH)
|
|
51
96
|
|
|
52
97
|
|
|
53
|
-
def no_gpu_batch_size(limit: int, probe: str) -> int:
|
|
54
|
-
"""The batch size to fall back on when there is no GPU to probe
|
|
55
|
-
batch_size = min(smaller_pot(limit), NO_GPU_BATCH_SIZE)
|
|
98
|
+
def no_gpu_batch_size(limit: int, probe: str, minimum: int = 1) -> int:
|
|
99
|
+
"""The batch size to fall back on when there is no GPU to probe; never below ``minimum``."""
|
|
100
|
+
batch_size = max(min(smaller_pot(limit), NO_GPU_BATCH_SIZE), minimum)
|
|
56
101
|
logger.warning('%s: CUDA is unavailable; using batch size %d without probing', probe, batch_size)
|
|
57
102
|
return batch_size
|
|
58
103
|
|
|
@@ -11,47 +11,185 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import gc
|
|
13
13
|
import logging
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from argparse import ArgumentParser
|
|
14
16
|
from collections.abc import Callable
|
|
15
17
|
|
|
16
18
|
import torch
|
|
17
19
|
|
|
18
|
-
from .batch_size import
|
|
20
|
+
from .batch_size import (
|
|
21
|
+
MAX_BATCH_SIZE,
|
|
22
|
+
REQUESTED_BATCH_SIZE,
|
|
23
|
+
VRAM_LIMIT_GB_FLAG,
|
|
24
|
+
VRAM_LIMIT_GB_HELP,
|
|
25
|
+
dataset_limit,
|
|
26
|
+
find_batch_size,
|
|
27
|
+
is_out_of_memory,
|
|
28
|
+
no_gpu_batch_size,
|
|
29
|
+
)
|
|
19
30
|
|
|
20
31
|
logger = logging.getLogger(__name__)
|
|
21
32
|
|
|
22
33
|
SAFETY_MARGIN = 0.05
|
|
23
34
|
"""Share of the budget held back while probing, against allocator fragmentation later on."""
|
|
24
35
|
|
|
36
|
+
DEFAULT_STEPS_PER_TRIAL = 4
|
|
37
|
+
"""Training steps a batch size must survive before it counts as fitting.
|
|
38
|
+
|
|
39
|
+
Some trainings reach their peak only after a few steps: an optimizer that allocates its state on
|
|
40
|
+
the first one, gradients that accumulate, a scaler that skips the first updates.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
_budget_gb: float = 0
|
|
44
|
+
"""What :func:`limit_cuda_memory` capped this process to; 0 means the whole card."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ProbeStep(ABC):
|
|
48
|
+
"""A training cycle built for one batch-size probe and released once the probe is done.
|
|
49
|
+
|
|
50
|
+
:func:`measure_batch_size` builds it through a factory, after the safety margin is reserved,
|
|
51
|
+
and holds the only reference to it, so nothing built for the probe stays on the card for the
|
|
52
|
+
training that follows. A trial at one batch size is ``steps_per_trial`` calls of
|
|
53
|
+
:meth:`train_step` followed by one of :meth:`val_step`; a subclass implements both, the other
|
|
54
|
+
two are optional.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def train_step(self, batch_size: int) -> str | None:
|
|
59
|
+
"""Run one training step at ``batch_size``; may return a detail to append to the log line."""
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def val_step(self, batch_size: int) -> str | None:
|
|
63
|
+
"""Run the validation a training at ``batch_size`` runs between epochs.
|
|
64
|
+
|
|
65
|
+
Validate at the batch size the training validates with, which need not be ``batch_size``
|
|
66
|
+
(YOLOv5 halves it). A training that does not validate returns ``None`` without running
|
|
67
|
+
anything.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def on_out_of_memory(self) -> None: # noqa: B027
|
|
71
|
+
"""Drop what a trial that ran out of memory left behind (an optimizer's gradients, say)."""
|
|
72
|
+
|
|
73
|
+
def release(self) -> None: # noqa: B027
|
|
74
|
+
"""Undo what building the step changed outside it, such as moving the real model off the card."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def measure_batch_size(build_step: Callable[[], ProbeStep], *, max_batch_size: int = 0,
|
|
78
|
+
sample_count: int | None = None, probe: str = 'batch-size probe',
|
|
79
|
+
minimum: int = 1, steps_per_trial: int = DEFAULT_STEPS_PER_TRIAL) -> int:
|
|
80
|
+
"""Settle a training's batch size against what it asked for and what the card allows.
|
|
81
|
+
|
|
82
|
+
Every training enters here, whatever shape its hyperparameters have; :func:`probe_batch_size` is
|
|
83
|
+
for a probe with no requested size to honour, such as a detection pass bounded only by how many
|
|
84
|
+
images there are. The step is built only once there is a card to probe.
|
|
85
|
+
|
|
86
|
+
:param build_step: Builds the step the trials run; called once, after the margin is reserved.
|
|
87
|
+
:param max_batch_size: What the training asked for, as carried in the
|
|
88
|
+
:data:`~.batch_size.REQUESTED_BATCH_SIZE` hyperparameter: the largest batch it may use,
|
|
89
|
+
measured rather than trusted. A size that fits is used as asked for, whether or not it is a
|
|
90
|
+
power of two; one that does not becomes the largest power of two below it that does. 0 means
|
|
91
|
+
the card decides alone. A request below ``minimum`` is raised to it, with a warning.
|
|
92
|
+
:param sample_count: Samples in the training split, when the caller knows it. The search is
|
|
93
|
+
then bounded by :func:`~.batch_size.dataset_limit`; that bound is never used as the exact
|
|
94
|
+
candidate.
|
|
95
|
+
:param probe: Names this probe in the log, so a node running several stays readable.
|
|
96
|
+
:param minimum: Smallest size to try, taking precedence over ``max_batch_size``; see
|
|
97
|
+
:func:`~.batch_size.find_batch_size`.
|
|
98
|
+
:param steps_per_trial: Training steps a size must survive, each trial then ending in one
|
|
99
|
+
validation step.
|
|
100
|
+
:raises InsufficientMemoryError: If not even ``minimum`` fits.
|
|
101
|
+
:raises ValueError: If the training asked for a negative batch size, or ``steps_per_trial`` is
|
|
102
|
+
below one.
|
|
103
|
+
"""
|
|
104
|
+
if max_batch_size < 0:
|
|
105
|
+
raise ValueError(f'{REQUESTED_BATCH_SIZE} must be >= 0, got {max_batch_size}')
|
|
106
|
+
if steps_per_trial < 1:
|
|
107
|
+
raise ValueError(f'steps_per_trial must be >= 1, got {steps_per_trial}')
|
|
108
|
+
if 0 < max_batch_size < minimum:
|
|
109
|
+
logger.warning('%s: requested %s=%d is below the trainer minimum of %d; using %d', probe,
|
|
110
|
+
REQUESTED_BATCH_SIZE, max_batch_size, minimum, minimum)
|
|
111
|
+
|
|
112
|
+
limit = max_batch_size
|
|
113
|
+
if sample_count is not None:
|
|
114
|
+
limit = min(limit or MAX_BATCH_SIZE, dataset_limit(sample_count))
|
|
115
|
+
logger.info('%s: %d training samples allow at most %d per batch', probe, sample_count,
|
|
116
|
+
dataset_limit(sample_count))
|
|
117
|
+
|
|
118
|
+
return _probe(build_step, probe=probe, limit=limit, steps_per_trial=steps_per_trial,
|
|
119
|
+
candidate=max_batch_size, minimum=minimum)
|
|
25
120
|
|
|
26
|
-
def probe_batch_size(run_batch: Callable[[int], str | None], *, probe: str = 'batch-size probe',
|
|
27
|
-
limit: int = 0, vram_limit_gb: float = 0) -> int:
|
|
28
|
-
"""Find the largest power-of-two batch size ``run_batch`` fits into.
|
|
29
121
|
|
|
30
|
-
|
|
31
|
-
|
|
122
|
+
def probe_batch_size(run_batch: Callable[[int], str | None], *, probe: str = 'batch-size probe',
|
|
123
|
+
limit: int = 0) -> int:
|
|
124
|
+
"""Find the largest batch that fits for a pass with no requested size, such as a detection pass.
|
|
32
125
|
|
|
33
126
|
:param run_batch: Runs the batch; may return a detail to append to the log line.
|
|
34
127
|
:param probe: Names this probe in the log, so a node running several stays readable.
|
|
35
|
-
:param limit: Caps the search
|
|
128
|
+
:param limit: Caps the search; 0 means
|
|
36
129
|
:data:`~learning_loop_node.trainer.batch_size.MAX_BATCH_SIZE`.
|
|
37
|
-
:
|
|
38
|
-
:raises InsufficientMemoryError: If not even a batch size of 1 fits.
|
|
130
|
+
:raises InsufficientMemoryError: If not even a batch of one fits.
|
|
39
131
|
"""
|
|
40
|
-
|
|
132
|
+
return _probe(lambda: _PassStep(run_batch), probe=probe, limit=limit, steps_per_trial=1)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class _PassStep(ProbeStep):
|
|
136
|
+
"""A plain callable as a step; there is nothing of its own to build, validate or release."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, run_batch: Callable[[int], str | None]) -> None:
|
|
139
|
+
self._run_batch = run_batch
|
|
140
|
+
|
|
141
|
+
def train_step(self, batch_size: int) -> str | None:
|
|
142
|
+
return self._run_batch(batch_size)
|
|
143
|
+
|
|
144
|
+
def val_step(self, batch_size: int) -> None:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _probe(build_step: Callable[[], ProbeStep], *, probe: str, limit: int, steps_per_trial: int,
|
|
149
|
+
candidate: int = 0, minimum: int = 1) -> int:
|
|
150
|
+
"""Everything of a probe but the step: the GPU check, the margin, the search, the release.
|
|
151
|
+
|
|
152
|
+
The margin is a share of the budget :func:`limit_cuda_memory` set in this process.
|
|
153
|
+
"""
|
|
154
|
+
bound = max(limit or MAX_BATCH_SIZE, minimum)
|
|
41
155
|
|
|
42
156
|
if not torch.cuda.is_available():
|
|
43
|
-
return no_gpu_batch_size(
|
|
157
|
+
return no_gpu_batch_size(bound, probe, minimum)
|
|
44
158
|
|
|
45
|
-
margin = reserve_margin(
|
|
159
|
+
margin = reserve_margin(probe=probe)
|
|
46
160
|
try:
|
|
47
|
-
chosen =
|
|
161
|
+
chosen = _search(build_step, probe=probe, bound=bound, steps_per_trial=steps_per_trial,
|
|
162
|
+
minimum=minimum, candidate=candidate)
|
|
48
163
|
finally:
|
|
49
164
|
del margin
|
|
50
165
|
free_cuda_memory()
|
|
51
|
-
logger.info('%s: selected batch size %d (upper bound %d)', probe, chosen,
|
|
166
|
+
logger.info('%s: selected batch size %d (upper bound %d)', probe, chosen, bound)
|
|
52
167
|
return chosen
|
|
53
168
|
|
|
54
169
|
|
|
170
|
+
def _search(build_step: Callable[[], ProbeStep], *, probe: str, bound: int, steps_per_trial: int,
|
|
171
|
+
minimum: int, candidate: int) -> int:
|
|
172
|
+
"""Build the step, search with it and release it; once this returns, nothing references it."""
|
|
173
|
+
step = build_step()
|
|
174
|
+
try:
|
|
175
|
+
fits = measured_fits(_trial(step, steps_per_trial), probe=probe, on_out_of_memory=step.on_out_of_memory)
|
|
176
|
+
return find_batch_size(fits, limit=bound, minimum=minimum, candidate=candidate)
|
|
177
|
+
finally:
|
|
178
|
+
step.release()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _trial(step: ProbeStep, steps_per_trial: int) -> Callable[[int], str | None]:
|
|
182
|
+
"""One trial of ``step``: its training steps, then its validation."""
|
|
183
|
+
def run_trial(batch_size: int) -> str | None:
|
|
184
|
+
train_detail = None
|
|
185
|
+
for _ in range(steps_per_trial):
|
|
186
|
+
train_detail = step.train_step(batch_size)
|
|
187
|
+
val_detail = step.val_step(batch_size)
|
|
188
|
+
return '; '.join(detail for detail in (train_detail, val_detail) if detail) or None
|
|
189
|
+
|
|
190
|
+
return run_trial
|
|
191
|
+
|
|
192
|
+
|
|
55
193
|
def measured_fits(run_batch: Callable[[int], str | None], *, probe: str,
|
|
56
194
|
on_out_of_memory: Callable[[], None] | None = None) -> Callable[[int], bool]:
|
|
57
195
|
"""Wrap ``run_batch`` into the ``fits`` predicate ``find_batch_size`` searches with.
|
|
@@ -84,38 +222,41 @@ def measured_fits(run_batch: Callable[[int], str | None], *, probe: str,
|
|
|
84
222
|
return fits
|
|
85
223
|
|
|
86
224
|
|
|
87
|
-
def reserve_margin(
|
|
225
|
+
def reserve_margin(*, probe: str) -> torch.Tensor:
|
|
88
226
|
"""Claim :data:`SAFETY_MARGIN` of the budget, so a trial competes against a smaller card.
|
|
89
227
|
|
|
90
228
|
Keep the returned tensor alive for as long as the probe runs: releasing it hands the margin
|
|
91
229
|
back, and the chosen size is no longer the size that was measured.
|
|
92
|
-
|
|
93
|
-
:param vram_limit_gb: The budget the margin is a share of; 0 means the whole card.
|
|
94
230
|
"""
|
|
95
|
-
margin_bytes = int(usable_memory_bytes(
|
|
231
|
+
margin_bytes = int(usable_memory_bytes() * SAFETY_MARGIN)
|
|
96
232
|
logger.info('%s: keeping %.0f MB free as a safety margin', probe, margin_bytes / 1024**2)
|
|
97
233
|
return torch.empty(margin_bytes, dtype=torch.uint8, device='cuda')
|
|
98
234
|
|
|
99
235
|
|
|
100
|
-
def usable_memory_bytes(
|
|
101
|
-
"""How much GPU memory this process may allocate, honouring :func:`limit_cuda_memory`.
|
|
236
|
+
def usable_memory_bytes() -> int:
|
|
237
|
+
"""How much GPU memory this process may allocate, honouring :func:`limit_cuda_memory`."""
|
|
238
|
+
if _budget_gb > 0:
|
|
239
|
+
return int(_budget_gb * 1024**3)
|
|
240
|
+
return torch.cuda.get_device_properties(None).total_memory
|
|
241
|
+
|
|
102
242
|
|
|
103
|
-
|
|
243
|
+
def add_vram_limit_argument(parser: ArgumentParser) -> None:
|
|
244
|
+
"""Give a spawned training script the same GPU budget flag its node has.
|
|
245
|
+
|
|
246
|
+
The spawned process still has to call :func:`limit_cuda_memory` with it.
|
|
104
247
|
"""
|
|
105
|
-
|
|
106
|
-
if vram_limit_gb <= 0:
|
|
107
|
-
return total_bytes
|
|
108
|
-
return min(total_bytes, int(vram_limit_gb * 1024**3))
|
|
248
|
+
parser.add_argument(VRAM_LIMIT_GB_FLAG, type=float, default=0, help=VRAM_LIMIT_GB_HELP)
|
|
109
249
|
|
|
110
250
|
|
|
111
251
|
def limit_cuda_memory(vram_limit_gb: float) -> None:
|
|
112
252
|
"""Cap how much of the GPU this process may allocate, to ``vram_limit_gb`` gigabytes.
|
|
113
253
|
|
|
114
254
|
Call this once per process that touches the GPU, a spawned training process included: the
|
|
115
|
-
cap does not survive the spawn.
|
|
255
|
+
cap does not survive the spawn. A probe in this process then measures against the same budget.
|
|
116
256
|
|
|
117
257
|
:param vram_limit_gb: 0 or less means no cap.
|
|
118
258
|
"""
|
|
259
|
+
global _budget_gb # pylint: disable=global-statement
|
|
119
260
|
if vram_limit_gb <= 0 or not torch.cuda.is_available():
|
|
120
261
|
return
|
|
121
262
|
|
|
@@ -129,6 +270,7 @@ def limit_cuda_memory(vram_limit_gb: float) -> None:
|
|
|
129
270
|
return
|
|
130
271
|
|
|
131
272
|
torch.cuda.set_per_process_memory_fraction(fraction, None)
|
|
273
|
+
_budget_gb = vram_limit_gb
|
|
132
274
|
logger.info('Limiting VRAM usage to %.1f GB of %.1f GB (%.0f%%)', vram_limit_gb, total_gb, fraction * 100)
|
|
133
275
|
|
|
134
276
|
|
|
@@ -5,6 +5,18 @@ class CriticalError(Exception):
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
class TrainingRefusedError(Exception):
|
|
9
|
+
"""Raised when a trainer cannot take on a training it was asked to begin; nothing was set up."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TrainingSlotTakenError(TrainingRefusedError):
|
|
13
|
+
"""Raised when a sibling node holds the training slot."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, holder: str) -> None:
|
|
16
|
+
super().__init__(f'the training slot is held by {holder}')
|
|
17
|
+
self.holder = holder
|
|
18
|
+
|
|
19
|
+
|
|
8
20
|
class InsufficientMemoryError(RuntimeError):
|
|
9
21
|
"""Raised when not even the smallest unit of work fits in memory."""
|
|
10
22
|
|
|
@@ -26,8 +26,9 @@ from ..helpers.misc import (
|
|
|
26
26
|
is_valid_uuid4,
|
|
27
27
|
)
|
|
28
28
|
from .downloader import TrainingsDownloader
|
|
29
|
-
from .exceptions import CriticalError, NodeNeedsRestartError
|
|
29
|
+
from .exceptions import CriticalError, NodeNeedsRestartError, TrainingRefusedError
|
|
30
30
|
from .io_helpers import ActiveTrainingIO, EnvironmentVars, LastTrainingIO
|
|
31
|
+
from .training_slot import TrainingSlot, training_slot_from_env
|
|
31
32
|
|
|
32
33
|
if TYPE_CHECKING:
|
|
33
34
|
from .trainer_node import TrainerNode
|
|
@@ -37,7 +38,7 @@ logger = logging.getLogger('learning_loop_node.trainer_logic_generic')
|
|
|
37
38
|
|
|
38
39
|
class TrainerLogicGeneric(ABC):
|
|
39
40
|
|
|
40
|
-
def __init__(self, model_format: str) -> None:
|
|
41
|
+
def __init__(self, model_format: str, training_slot: TrainingSlot | None = None) -> None:
|
|
41
42
|
|
|
42
43
|
# NOTE: model_format is used in the file path for the model on the server:
|
|
43
44
|
# It acts as a key for list of files (cf. _get_latest_model_files)
|
|
@@ -54,6 +55,7 @@ class TrainerLogicGeneric(ABC):
|
|
|
54
55
|
self._training: Optional[Training] = None
|
|
55
56
|
self._active_training_io: Optional[ActiveTrainingIO] = None
|
|
56
57
|
self._environment_vars = EnvironmentVars()
|
|
58
|
+
self.training_slot = training_slot or training_slot_from_env()
|
|
57
59
|
|
|
58
60
|
# ---------------------------------------- PROPERTIES TO AVOID CHECKING FOR NONE ----------------------------------------
|
|
59
61
|
|
|
@@ -98,9 +100,17 @@ class TrainerLogicGeneric(ABC):
|
|
|
98
100
|
|
|
99
101
|
@property
|
|
100
102
|
def state(self) -> str:
|
|
101
|
-
"""Returns the current state of the training.
|
|
103
|
+
"""Returns the current state of the training.
|
|
104
|
+
|
|
105
|
+
Without a training the state is `idle`, or `blocked` while a sibling node holds the training slot.
|
|
106
|
+
A training resumed after a restart reports `waiting_for_slot` until it gets the slot: it has a
|
|
107
|
+
training, so it is never `blocked`.
|
|
102
108
|
"""
|
|
103
|
-
if
|
|
109
|
+
if not self.training_active:
|
|
110
|
+
return TrainerState.Blocked.value if self.training_slot.taken_by_sibling else TrainerState.Idle.value
|
|
111
|
+
if not self.training_slot.held:
|
|
112
|
+
return TrainerState.WaitingForSlot.value
|
|
113
|
+
if self.training.training_state is None:
|
|
104
114
|
return TrainerState.Idle.value
|
|
105
115
|
return self.training.training_state
|
|
106
116
|
|
|
@@ -213,8 +223,21 @@ class TrainerLogicGeneric(ABC):
|
|
|
213
223
|
|
|
214
224
|
async def begin_training(self, organization: str, project: str, training_config: Dict) -> None:
|
|
215
225
|
"""Called on `begin_training` event from the Learning Loop.
|
|
226
|
+
|
|
227
|
+
The training slot is taken before anything is set up, so a refused training leaves the
|
|
228
|
+
previous training folders in place.
|
|
229
|
+
|
|
230
|
+
:raise TrainingRefusedError: if a training is active or a sibling node holds the training slot.
|
|
216
231
|
"""
|
|
217
|
-
self.
|
|
232
|
+
if self.training_active:
|
|
233
|
+
raise TrainingRefusedError(f'training {self.training.id} is still active')
|
|
234
|
+
context = Context(organization=organization, project=project)
|
|
235
|
+
self.training_slot.claim(self._slot_claim(context, training_config.get('training_number')))
|
|
236
|
+
try:
|
|
237
|
+
self._init_new_training(context, training_config)
|
|
238
|
+
except Exception:
|
|
239
|
+
self.training_slot.release()
|
|
240
|
+
raise
|
|
218
241
|
self._begin_training_task()
|
|
219
242
|
|
|
220
243
|
def _begin_training_task(self) -> None:
|
|
@@ -242,6 +265,7 @@ class TrainerLogicGeneric(ABC):
|
|
|
242
265
|
"""
|
|
243
266
|
self.errors.reset_all()
|
|
244
267
|
try:
|
|
268
|
+
await self.training_slot.wait_for(self._slot_claim(self.training.context, self.training.training_number))
|
|
245
269
|
await self._training_loop()
|
|
246
270
|
except asyncio.CancelledError:
|
|
247
271
|
if not self.shutdown_event.is_set():
|
|
@@ -254,6 +278,12 @@ class TrainerLogicGeneric(ABC):
|
|
|
254
278
|
logger.info('CancelledError in _run - shutting down')
|
|
255
279
|
except Exception:
|
|
256
280
|
logger.exception('(Ignored) exception in trainer_logic._run:')
|
|
281
|
+
finally:
|
|
282
|
+
self._training = None
|
|
283
|
+
self.training_slot.release()
|
|
284
|
+
|
|
285
|
+
def _slot_claim(self, context: Context, training_number: Any) -> str:
|
|
286
|
+
return f'node {self.node.uuid}, training #{training_number} of {context.organization}/{context.project}'
|
|
257
287
|
|
|
258
288
|
# ---------------------------------------- TRAINING STATES ----------------------------------------
|
|
259
289
|
|
|
@@ -7,7 +7,10 @@ from typing import Dict, Optional
|
|
|
7
7
|
from fastapi.encoders import jsonable_encoder
|
|
8
8
|
from socketio import AsyncClient, exceptions
|
|
9
9
|
|
|
10
|
+
from ..data_classes import SocketResponse
|
|
11
|
+
from ..enums import TrainerState
|
|
10
12
|
from ..node import Node
|
|
13
|
+
from .exceptions import TrainingRefusedError
|
|
11
14
|
from .io_helpers import LastTrainingIO
|
|
12
15
|
from .rest import backdoor_controls
|
|
13
16
|
from .trainer_logic_generic import TrainerLogicGeneric
|
|
@@ -64,7 +67,11 @@ class TrainerNode(Node):
|
|
|
64
67
|
@sio_client.event
|
|
65
68
|
async def begin_training(organization: str, project: str, details: Dict):
|
|
66
69
|
self.log.info('received begin_training from server')
|
|
67
|
-
|
|
70
|
+
try:
|
|
71
|
+
await self.trainer_logic.begin_training(organization, project, details)
|
|
72
|
+
except TrainingRefusedError as e:
|
|
73
|
+
self.log.warning('refused begin_training: %s', e)
|
|
74
|
+
return SocketResponse(success=False, error_msg=str(e), payload='refused')
|
|
68
75
|
return True
|
|
69
76
|
|
|
70
77
|
@sio_client.event
|
|
@@ -102,7 +109,7 @@ class TrainerNode(Node):
|
|
|
102
109
|
if not self._idle_timeout:
|
|
103
110
|
return
|
|
104
111
|
|
|
105
|
-
if self.trainer_logic.state
|
|
112
|
+
if self.trainer_logic.state in (TrainerState.Idle, TrainerState.Blocked):
|
|
106
113
|
if self._first_idle_time is None:
|
|
107
114
|
self._first_idle_time = time.time()
|
|
108
115
|
idle_time = time.time() - self._first_idle_time
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""The right to use the GPU, shared between the trainer nodes on one machine.
|
|
2
|
+
|
|
3
|
+
A node holds the slot from the moment it accepts a training until the training is cleared.
|
|
4
|
+
"""
|
|
5
|
+
import asyncio
|
|
6
|
+
import fcntl
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
import weakref
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .exceptions import TrainingSlotTakenError
|
|
15
|
+
|
|
16
|
+
ENV_VAR = 'TRAINING_SLOT_LOCK'
|
|
17
|
+
_CLAIM_ATTEMPTS = 3
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger('learning_loop_node.training_slot')
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TrainingSlot(ABC):
|
|
23
|
+
"""The slot as a trainer uses it; an adapter implements `_acquire`, `_sibling_holder`, `release` and `held`."""
|
|
24
|
+
|
|
25
|
+
def claim(self, training: str) -> None:
|
|
26
|
+
"""Take the slot for `training` without waiting; a no-op if this slot already holds it.
|
|
27
|
+
|
|
28
|
+
:raise TrainingSlotTakenError: if a sibling node holds the slot.
|
|
29
|
+
"""
|
|
30
|
+
holder = self._describe(training)
|
|
31
|
+
for _ in range(_CLAIM_ATTEMPTS - 1):
|
|
32
|
+
try:
|
|
33
|
+
self._acquire(holder)
|
|
34
|
+
return
|
|
35
|
+
except TrainingSlotTakenError:
|
|
36
|
+
if self._sibling_holder() is not None:
|
|
37
|
+
raise
|
|
38
|
+
self._acquire(holder)
|
|
39
|
+
|
|
40
|
+
async def wait_for(self, training: str) -> None:
|
|
41
|
+
"""Take the slot for `training`, waiting as long as a sibling node holds it."""
|
|
42
|
+
try:
|
|
43
|
+
self.claim(training)
|
|
44
|
+
return
|
|
45
|
+
except TrainingSlotTakenError as e:
|
|
46
|
+
logger.info('%s - waiting', e)
|
|
47
|
+
while True:
|
|
48
|
+
await asyncio.sleep(1)
|
|
49
|
+
try:
|
|
50
|
+
self.claim(training)
|
|
51
|
+
except TrainingSlotTakenError:
|
|
52
|
+
continue
|
|
53
|
+
logger.info('acquired training slot')
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def release(self) -> None:
|
|
58
|
+
"""Give the slot back; a no-op if this slot does not hold it."""
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def held(self) -> bool:
|
|
63
|
+
"""Whether this slot object holds the slot."""
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def taken_by_sibling(self) -> bool:
|
|
67
|
+
return not self.held and self._sibling_holder() is not None
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def _acquire(self, holder: str) -> None:
|
|
71
|
+
"""Take the slot without waiting; a no-op if this slot already holds it.
|
|
72
|
+
|
|
73
|
+
:raise TrainingSlotTakenError: if the slot is held elsewhere, including by a momentary probe.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
def _sibling_holder(self) -> str | None:
|
|
78
|
+
"""Who holds the slot, None if it is free or cannot be inspected; only asked while this slot does not hold it."""
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _describe(training: str) -> str:
|
|
82
|
+
return f'pid {os.getpid()} on {socket.gethostname()}, {training}'
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AlwaysFreeSlot(TrainingSlot):
|
|
86
|
+
"""The slot of a node that has the GPU to itself."""
|
|
87
|
+
|
|
88
|
+
def __init__(self) -> None:
|
|
89
|
+
self._held = False
|
|
90
|
+
|
|
91
|
+
def release(self) -> None:
|
|
92
|
+
self._held = False
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def held(self) -> bool:
|
|
96
|
+
return self._held
|
|
97
|
+
|
|
98
|
+
def _acquire(self, holder: str) -> None:
|
|
99
|
+
self._held = True
|
|
100
|
+
|
|
101
|
+
def _sibling_holder(self) -> str | None:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class FileTrainingSlot(TrainingSlot):
|
|
106
|
+
"""A slot backed by an exclusive `flock` on a file every sibling node mounts.
|
|
107
|
+
|
|
108
|
+
The kernel drops the lock with the process, so a node that dies in any way frees the slot.
|
|
109
|
+
The lock belongs to the inode: the file is only ever truncated and rewritten in place, never
|
|
110
|
+
replaced, and the mounted path is its directory.
|
|
111
|
+
|
|
112
|
+
The file's content names the holder and is diagnostic only — `_sibling_holder` tells a free slot
|
|
113
|
+
from a taken one by trying the lock, and that probe holds the lock for an instant, so a
|
|
114
|
+
claim that loses against a probe tries again. The file is made writable for everyone,
|
|
115
|
+
since sibling containers may run as different users.
|
|
116
|
+
|
|
117
|
+
A forked child shares the parent's lock and would keep it alive after the parent releases it,
|
|
118
|
+
so the child closes its copy right after the fork; the parent's lock is unaffected.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
_instances: 'weakref.WeakSet[FileTrainingSlot]' = weakref.WeakSet()
|
|
122
|
+
|
|
123
|
+
def __init__(self, path: str | Path) -> None:
|
|
124
|
+
self.path = Path(path)
|
|
125
|
+
self._fd: int | None = None
|
|
126
|
+
FileTrainingSlot._instances.add(self)
|
|
127
|
+
|
|
128
|
+
def _acquire(self, holder: str) -> None:
|
|
129
|
+
if self._fd is not None:
|
|
130
|
+
return
|
|
131
|
+
fd = self._open()
|
|
132
|
+
try:
|
|
133
|
+
self._lock(fd)
|
|
134
|
+
os.ftruncate(fd, 0)
|
|
135
|
+
os.write(fd, holder.encode())
|
|
136
|
+
except BaseException:
|
|
137
|
+
os.close(fd)
|
|
138
|
+
raise
|
|
139
|
+
self._fd = fd
|
|
140
|
+
|
|
141
|
+
def release(self) -> None:
|
|
142
|
+
if self._fd is None:
|
|
143
|
+
return
|
|
144
|
+
try:
|
|
145
|
+
os.ftruncate(self._fd, 0)
|
|
146
|
+
except OSError:
|
|
147
|
+
logger.warning('cannot clear the holder from training slot %s', self.path, exc_info=True)
|
|
148
|
+
finally:
|
|
149
|
+
os.close(self._fd)
|
|
150
|
+
self._fd = None
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def held(self) -> bool:
|
|
154
|
+
return self._fd is not None
|
|
155
|
+
|
|
156
|
+
def _sibling_holder(self) -> str | None:
|
|
157
|
+
try:
|
|
158
|
+
fd = os.open(self.path, os.O_RDONLY)
|
|
159
|
+
except FileNotFoundError:
|
|
160
|
+
return None
|
|
161
|
+
except OSError:
|
|
162
|
+
logger.warning('cannot inspect training slot %s - treating it as free', self.path, exc_info=True)
|
|
163
|
+
return None
|
|
164
|
+
try:
|
|
165
|
+
self._lock(fd)
|
|
166
|
+
except TrainingSlotTakenError as e:
|
|
167
|
+
return e.holder
|
|
168
|
+
finally:
|
|
169
|
+
os.close(fd)
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def _drop_inherited_locks(cls) -> None:
|
|
174
|
+
for slot in list(cls._instances):
|
|
175
|
+
if slot._fd is not None:
|
|
176
|
+
os.close(slot._fd)
|
|
177
|
+
slot._fd = None
|
|
178
|
+
|
|
179
|
+
def _open(self) -> int:
|
|
180
|
+
fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o666)
|
|
181
|
+
try:
|
|
182
|
+
os.fchmod(fd, 0o666)
|
|
183
|
+
except PermissionError:
|
|
184
|
+
pass
|
|
185
|
+
return fd
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _lock(fd: int) -> None:
|
|
189
|
+
try:
|
|
190
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
191
|
+
except BlockingIOError as e:
|
|
192
|
+
raise TrainingSlotTakenError(os.read(fd, 4096).decode(errors='replace') or 'unknown') from e
|
|
193
|
+
|
|
194
|
+
os.register_at_fork(after_in_child=FileTrainingSlot._drop_inherited_locks) # pylint: disable=protected-access
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def training_slot_from_env() -> TrainingSlot:
|
|
198
|
+
"""The slot `TRAINING_SLOT_LOCK` names, or a free one if the variable is unset.
|
|
199
|
+
|
|
200
|
+
:raise FileNotFoundError: if the lock file's directory does not exist.
|
|
201
|
+
"""
|
|
202
|
+
path = os.environ.get(ENV_VAR)
|
|
203
|
+
if not path:
|
|
204
|
+
return AlwaysFreeSlot()
|
|
205
|
+
if not Path(path).parent.is_dir():
|
|
206
|
+
raise FileNotFoundError(f'{ENV_VAR}={path}: the directory does not exist; mount it from the host')
|
|
207
|
+
return FileTrainingSlot(path)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning_loop_node
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.25.0
|
|
4
4
|
Summary: Python Library for Nodes which connect to the Zauberzeug Learning Loop
|
|
5
5
|
Author-email: Zauberzeug GmbH <info@zauberzeug.com>
|
|
6
6
|
License: MIT
|
|
@@ -60,11 +60,14 @@ You can configure connection to our Learning Loop by specifying the following en
|
|
|
60
60
|
| LOOP_PROJECT | PROJECT | Project ID | Detector | - |
|
|
61
61
|
| MIN_UNCERTAIN_THRESHOLD | - | smallest confidence (float) at which auto-upload will happen | Detector (opt.) | 0.3 |
|
|
62
62
|
| MAX_UNCERTAIN_THRESHOLD | - | largest confidence (float) at which auto-upload will happen | Detector (opt.) | 0.6 |
|
|
63
|
+
| MAX_OUTBOX_SIZE | - | max. number of non-priority images queued for upload | Detector (opt.) | 1000 |
|
|
63
64
|
| EXCLUSIVE_MODEL_BUILD | - | Reject detections during update to save VRAM (set to 1) | Detector (opt.) | 0 |
|
|
64
65
|
| INFERENCE_BATCH_SIZE | - | Batch size of trainer when calculating detections | Trainer (opt.) | 10 |
|
|
66
|
+
| VRAM_LIMIT_GB | - | GPU memory (GB) a training may use; the batch size is probed against it (`--vram-limit-gb`, trainers built with `node_parser(vram_limit=True)`) | Trainer (opt.) | 0 (whole card) |
|
|
65
67
|
| RESTART_AFTER_TRAINING | - | Restart the trainer after training (set to 1) | Trainer (opt.) | 0 |
|
|
66
68
|
| KEEP_OLD_TRAININGS | - | Do not delete old trainings (set to 1) | Trainer (opt.) | 0 |
|
|
67
69
|
| TRAINER_IDLE_TIMEOUT_SEC | - | Automatically shutdown trainer after timeout (in seconds) | Trainer (opt.) | 0 (disabled) |
|
|
70
|
+
| TRAINING_SLOT_LOCK | - | Lock file shared by the trainers on one GPU (see Trainer Node) | Trainer (opt.) | - (disabled) |
|
|
68
71
|
| USE_BACKDOOR_CONTROLS | - | Always enable backdoor controls (set to 1) | Trainer / Detector (opt.) | 0 |
|
|
69
72
|
|
|
70
73
|
Note that organization and project IDs are always lower case and may differ from the names in the Learning Loop which can have uppercase letters.
|
|
@@ -73,6 +76,16 @@ Where a name has an alias, either spelling works. If both are set to **different
|
|
|
73
76
|
prefixed name wins and a warning names the value used — the variable is never treated as unset,
|
|
74
77
|
which would otherwise let `LOOP_HOST` fall back to its default of `learning-loop.ai`.
|
|
75
78
|
|
|
79
|
+
#### Debug logging
|
|
80
|
+
|
|
81
|
+
Every node type (Detector, Trainer, Annotator, Converter) has a REST endpoint to switch its own log level between `INFO` and `DEBUG` at runtime. Send the plain text `on` or `off` with a PUT request:
|
|
82
|
+
|
|
83
|
+
- Turn debug logging on: `curl -X PUT -d "on" http://localhost/debug_logging`
|
|
84
|
+
- Turn debug logging off: `curl -X PUT -d "off" http://localhost/debug_logging`
|
|
85
|
+
|
|
86
|
+
The response repeats the new state. Any other body returns status code 400.
|
|
87
|
+
Only the node's own logger is affected; third-party loggers such as `httpx` keep their level. The setting is not persistent, a restart brings the log level back to `INFO`.
|
|
88
|
+
|
|
76
89
|
#### Testing
|
|
77
90
|
|
|
78
91
|
We use github actions for CI. Tests can also be executed locally by running
|
|
@@ -238,6 +251,20 @@ Trainers fetch the images and anntoations from the Learning Loop to train new mo
|
|
|
238
251
|
|
|
239
252
|
- if the command line tool "jpeginfo" is installed, the downloader will drop corrupted images automatically
|
|
240
253
|
|
|
254
|
+
### Several trainers on one GPU
|
|
255
|
+
|
|
256
|
+
Trainers that share a GPU (say, one per model architecture on the same machine) coordinate through
|
|
257
|
+
a lock file: mount one host directory into each of them and point `TRAINING_SLOT_LOCK` at a file
|
|
258
|
+
inside it, e.g. `/slot/training.lock`. A trainer takes the lock when it accepts a training and holds
|
|
259
|
+
it until the training is cleaned up, including detection and upload. While a sibling holds it, the
|
|
260
|
+
others report the state `blocked` instead of `idle`, so the Learning Loop neither offers them for a
|
|
261
|
+
training nor dispatches queued jobs to them; a trainer that is sent `begin_training` regardless
|
|
262
|
+
refuses it with `{"success": false, "error_msg": ...}` in the acknowledgement and sets nothing up. A
|
|
263
|
+
training resumed after a restart cannot be refused — it waits for the lock instead, reporting
|
|
264
|
+
`waiting_for_slot` meanwhile. The lock is an `flock`, so the kernel releases it whenever the holding
|
|
265
|
+
process ends, however it ends — there is nothing to clean up after a crash. Mount the directory,
|
|
266
|
+
not the file, and never replace the file: the lock belongs to its inode.
|
|
267
|
+
|
|
241
268
|
## Converter Node
|
|
242
269
|
|
|
243
270
|
A Conveter Node converts models from one format into another.
|
|
@@ -22,7 +22,7 @@ learning_loop_node/detector/detector_logic.py,sha256=XJaHNiw5PaHoCWnJ-qsxdUELLev
|
|
|
22
22
|
learning_loop_node/detector/detector_node.py,sha256=GC5CaGcW_qVxyolDEz9U5W7y0ejZGEu9FZ__ckLxd7g,35728
|
|
23
23
|
learning_loop_node/detector/exceptions.py,sha256=C6KbNPlSbtfgDrZx2Hbhm7Suk9jVoR3fMRCO0CkrMsQ,196
|
|
24
24
|
learning_loop_node/detector/geometry.py,sha256=bZCF9xuK7qy6Ipv5aQYrTlwSmjaiUz5VEUYnc1zCe-w,1101
|
|
25
|
-
learning_loop_node/detector/outbox.py,sha256=
|
|
25
|
+
learning_loop_node/detector/outbox.py,sha256=HT0GHv6rsQzdbCD8DAT23m1-Q1XEvA_KgwAJLONyX_0,12506
|
|
26
26
|
learning_loop_node/detector/postprocess.py,sha256=fPBbgE6Obkej709zoFPGqA2gnbJNbpbMuZogilKy4pI,7274
|
|
27
27
|
learning_loop_node/detector/inbox_filter/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
28
28
|
learning_loop_node/detector/inbox_filter/cam_observation_history.py,sha256=slPGm87TbBCfDUdpRTAM5yOz1An8Br5X_2HFJOGSrFg,3540
|
|
@@ -39,31 +39,32 @@ learning_loop_node/enums/__init__.py,sha256=tjSrhztIQ8W656_QuXfTbbVNtH_wDXP5hpYZ
|
|
|
39
39
|
learning_loop_node/enums/annotator.py,sha256=mtTAw-8LJIrHcYkBjYHCZuhYEEHS6QzSK8k6BhLusvQ,285
|
|
40
40
|
learning_loop_node/enums/detector.py,sha256=Qvm5LWWR9BfsDxHEQ8YzaPaUuSmp4BescYuV4X4ikwE,512
|
|
41
41
|
learning_loop_node/enums/general.py,sha256=OSpcIpdFjuRByON2xBoiIXl1SgDREIqkwnaxkTFjnHU,164
|
|
42
|
-
learning_loop_node/enums/trainer.py,sha256=
|
|
42
|
+
learning_loop_node/enums/trainer.py,sha256=VdamD-k6Q8kMm_cwPA8iaEUfZvmTxLfsBHQpEi43Ahk,849
|
|
43
43
|
learning_loop_node/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
44
44
|
learning_loop_node/helpers/background_tasks.py,sha256=sbbS8JO1ls0ZCbNW68KOT9QLOFyFoJRXwwAq78iOmoA,3256
|
|
45
|
-
learning_loop_node/helpers/entrypoint.py,sha256=
|
|
45
|
+
learning_loop_node/helpers/entrypoint.py,sha256=U4vHFqgke6mXjDL-E4MND8RO7P1k2VBGwdDOOoJvD9Y,3915
|
|
46
46
|
learning_loop_node/helpers/environment_reader.py,sha256=zsoFysxNxBCOJzUwX4Pbm3ftYhxx5zsbOHQfD9VcfvA,1937
|
|
47
47
|
learning_loop_node/helpers/gdrive_downloader.py,sha256=zeYJciTAJVRpu_eFjwgYLCpIa6hU1d71anqEBb564Rk,1145
|
|
48
48
|
learning_loop_node/helpers/log_conf.py,sha256=hqVAa_9NnYEU6N0dcOKmph82p7MpgKqeF_eomTLYzWY,961
|
|
49
|
-
learning_loop_node/helpers/misc.py,sha256=
|
|
49
|
+
learning_loop_node/helpers/misc.py,sha256=zfy1SumqNBwBw6-BYignQJpwMeMf1k_MA3xhYt_GiG4,8939
|
|
50
50
|
learning_loop_node/helpers/run.py,sha256=_uox-j3_K_bL3yCAwy3JYSOiIxrnhzVxyxWpCe8_J9U,876
|
|
51
51
|
learning_loop_node/trainer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
52
|
-
learning_loop_node/trainer/batch_size.py,sha256=
|
|
53
|
-
learning_loop_node/trainer/cuda.py,sha256=
|
|
52
|
+
learning_loop_node/trainer/batch_size.py,sha256=LjA-zyY4hdnSMy6ZsdQOhWbmd5H_DI-9LG3yFInv04E,5476
|
|
53
|
+
learning_loop_node/trainer/cuda.py,sha256=ehpfWqPBCVFu06SnGsjmWbSDRJ8sZc-L_nCtemRLUOU,12567
|
|
54
54
|
learning_loop_node/trainer/downloader.py,sha256=AIyEKM4XYtd6VgmXrP3VayV9DpJzdURK1Brx81ePNSM,1470
|
|
55
|
-
learning_loop_node/trainer/exceptions.py,sha256=
|
|
55
|
+
learning_loop_node/trainer/exceptions.py,sha256=hhd23WvuqV-6fiJo3vCNLN0hmF6TxAH9C5Bc7pCtmUM,940
|
|
56
56
|
learning_loop_node/trainer/executor.py,sha256=XQ1TwgOF2pQv7bgzSaljDWG4I1ySjYXL8r9c_p9MX1Q,3947
|
|
57
57
|
learning_loop_node/trainer/io_helpers.py,sha256=zWl0-HLP8zxOG3ybm76vXgEozULj_1QRl95jkrF126k,7255
|
|
58
58
|
learning_loop_node/trainer/metrics.py,sha256=H1jQh5fHN3-cTdMTuH8mF_uODV0T9DBdqcPUD-ZqFAI,1439
|
|
59
59
|
learning_loop_node/trainer/subprocess.py,sha256=WxuTly0zhpClEjTd9wqoI_-7YTNMaiZpRHxu00vttKw,2647
|
|
60
60
|
learning_loop_node/trainer/test_executor.py,sha256=6BVGDN_6f5GEMMEvDLSG1yzMybSvgXaP5uYpSfsVPP0,2224
|
|
61
61
|
learning_loop_node/trainer/trainer_logic.py,sha256=Yaw1GNTC05rVLQczY6vKUgkymBYfm3_0XaR5C0q-CP4,9159
|
|
62
|
-
learning_loop_node/trainer/trainer_logic_generic.py,sha256=
|
|
63
|
-
learning_loop_node/trainer/trainer_node.py,sha256=
|
|
62
|
+
learning_loop_node/trainer/trainer_logic_generic.py,sha256=OtjdZlixz-2UXZkG_o5PaCdXCFvwgGuD5J4I9n5bROY,28812
|
|
63
|
+
learning_loop_node/trainer/trainer_node.py,sha256=GU4LyFA-JC9s4d-PbyKhKWyGMMIzTMqKJFNxM6tDJE4,5316
|
|
64
|
+
learning_loop_node/trainer/training_slot.py,sha256=Njq6Z0T2Lqlr24ftoIp11Pw2OxCo9Z2RGn1tuFhHqLY,6737
|
|
64
65
|
learning_loop_node/trainer/rest/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
65
66
|
learning_loop_node/trainer/rest/backdoor_controls.py,sha256=ZnK8ypY5r_q0-YZbtaOxhQThzuZvMsQHM5gJGESd_dE,5131
|
|
66
|
-
learning_loop_node-0.
|
|
67
|
-
learning_loop_node-0.
|
|
68
|
-
learning_loop_node-0.
|
|
69
|
-
learning_loop_node-0.
|
|
67
|
+
learning_loop_node-0.25.0.dist-info/METADATA,sha256=UOGaOXyynobyOFSEROGxOPH49odFh2-V2IGZ_LhQNsI,19855
|
|
68
|
+
learning_loop_node-0.25.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
69
|
+
learning_loop_node-0.25.0.dist-info/top_level.txt,sha256=nS5qiFc4stVF_VXVCVJxZnrqVOv3IGX5-_E3sLby6OE,19
|
|
70
|
+
learning_loop_node-0.25.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|