visin 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.
- visin/__init__.py +42 -0
- visin/__main__.py +5 -0
- visin/_internal/__init__.py +9 -0
- visin/_internal/config.py +112 -0
- visin/_internal/console.py +31 -0
- visin/_internal/reports.py +187 -0
- visin/_internal/sender.py +143 -0
- visin/_internal/serialize.py +85 -0
- visin/_internal/spool.py +306 -0
- visin/_internal/transport.py +303 -0
- visin/_version.py +1 -0
- visin/api.py +206 -0
- visin/cli.py +289 -0
- visin/errors.py +40 -0
- visin/integrations/__init__.py +5 -0
- visin/integrations/_metrics.py +49 -0
- visin/integrations/keras.py +91 -0
- visin/integrations/lightning.py +122 -0
- visin/offline.py +56 -0
- visin/py.typed +0 -0
- visin/run.py +1094 -0
- visin/system.py +293 -0
- visin-0.1.0.dist-info/METADATA +147 -0
- visin-0.1.0.dist-info/RECORD +27 -0
- visin-0.1.0.dist-info/WHEEL +4 -0
- visin-0.1.0.dist-info/entry_points.txt +2 -0
- visin-0.1.0.dist-info/licenses/LICENSE +21 -0
visin/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Report training runs to a Visin instance, and read them back.
|
|
2
|
+
|
|
3
|
+
import visin
|
|
4
|
+
|
|
5
|
+
with visin.init("unet baseline", project="road-seg") as run:
|
|
6
|
+
for epoch in range(1, epochs + 1):
|
|
7
|
+
train_loss, val_loss, val_iou = train_one_epoch()
|
|
8
|
+
run.log_epoch(epoch, train={"loss": train_loss}, val={"loss": val_loss, "mean_iou": val_iou})
|
|
9
|
+
|
|
10
|
+
``init`` reads ``VISIN_URL`` and ``VISIN_TOKEN``. With neither set it returns a
|
|
11
|
+
run that reports nothing, so the same script still runs on a laptop.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from ._internal.config import Settings, read_settings
|
|
15
|
+
from ._internal.console import enable_console_logging
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
from .api import Api, flatten
|
|
18
|
+
from .errors import ApiError, ConfigurationError, TransportError, VisinError
|
|
19
|
+
from .offline import SyncResult, pending, sync
|
|
20
|
+
from .run import Run, epoch_uuid_for, init
|
|
21
|
+
from .system import system_info, system_metrics
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"init",
|
|
25
|
+
"Run",
|
|
26
|
+
"Api",
|
|
27
|
+
"sync",
|
|
28
|
+
"pending",
|
|
29
|
+
"SyncResult",
|
|
30
|
+
"epoch_uuid_for",
|
|
31
|
+
"flatten",
|
|
32
|
+
"system_info",
|
|
33
|
+
"system_metrics",
|
|
34
|
+
"enable_console_logging",
|
|
35
|
+
"Settings",
|
|
36
|
+
"read_settings",
|
|
37
|
+
"VisinError",
|
|
38
|
+
"ApiError",
|
|
39
|
+
"TransportError",
|
|
40
|
+
"ConfigurationError",
|
|
41
|
+
"__version__",
|
|
42
|
+
]
|
visin/__main__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""How the package works: not part of its public API, and free to change in any release.
|
|
2
|
+
|
|
3
|
+
config settings from the environment and arguments
|
|
4
|
+
transport HTTP: retries that know which requests may repeat, errors, signed uploads
|
|
5
|
+
serialize NumPy scalars, tensors and NaN into JSON Visin accepts
|
|
6
|
+
reports each kind of report as data, and how it is delivered
|
|
7
|
+
sender the background thread reports are sent from, in order
|
|
8
|
+
spool reports kept on disk, and sending them later
|
|
9
|
+
"""
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Where configuration comes from.
|
|
2
|
+
|
|
3
|
+
There is no default API URL, on purpose. The integrations this package replaces
|
|
4
|
+
each hard-coded one instance's hostname, so a copy taken into a new project
|
|
5
|
+
reported into somebody else's Visin until the line was noticed. Unset means
|
|
6
|
+
disabled, which is loud in the log and harmless, rather than wrong and silent.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
from dataclasses import dataclass, replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..errors import ConfigurationError
|
|
18
|
+
|
|
19
|
+
# The first name of each is the one Visin's documentation uses. The others are
|
|
20
|
+
# what earlier drafts of this package read, and scripts already export them.
|
|
21
|
+
ENV_URL = ("VISIN_URL", "VISIN_API_URL")
|
|
22
|
+
ENV_TOKEN = ("VISIN_TOKEN", "VISIN_API_TOKEN")
|
|
23
|
+
ENV_TRAINING_UUID = ("VISIN_TRAINING_UUID",)
|
|
24
|
+
ENV_PROJECT = ("VISIN_PROJECT", "VISIN_PROJECT_ID")
|
|
25
|
+
ENV_VERIFY = "VISIN_VERIFY_SSL"
|
|
26
|
+
ENV_MODE = "VISIN_MODE"
|
|
27
|
+
ENV_DIR = "VISIN_DIR"
|
|
28
|
+
|
|
29
|
+
MODES = ("online", "offline", "disabled")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _first(names: tuple[str, ...]) -> str | None:
|
|
33
|
+
for name in names:
|
|
34
|
+
value = os.getenv(name)
|
|
35
|
+
if value and value.strip():
|
|
36
|
+
return value.strip()
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _flag(name: str, default: bool) -> bool:
|
|
41
|
+
raw = os.getenv(name)
|
|
42
|
+
if raw is None:
|
|
43
|
+
return default
|
|
44
|
+
return raw.strip().lower() not in {"0", "false", "no", "off"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def default_directory() -> Path:
|
|
48
|
+
"""Where reports wait when they cannot be sent: ``~/.visin``.
|
|
49
|
+
|
|
50
|
+
Home rather than the working directory because on a cluster the login node,
|
|
51
|
+
where ``visin sync`` runs, shares home with the compute nodes and usually
|
|
52
|
+
shares nothing else.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
return Path.home() / ".visin"
|
|
56
|
+
except RuntimeError: # no HOME, as in some minimal containers
|
|
57
|
+
return Path(tempfile.gettempdir()) / "visin"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Settings:
|
|
62
|
+
url: str | None
|
|
63
|
+
token: str | None
|
|
64
|
+
training_uuid: str | None
|
|
65
|
+
project: str | None
|
|
66
|
+
verify_ssl: bool
|
|
67
|
+
mode: str
|
|
68
|
+
directory: Path
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def configured(self) -> bool:
|
|
72
|
+
"""Enough to talk to a server at all.
|
|
73
|
+
|
|
74
|
+
The training UUID is not part of this: a run that registers itself has
|
|
75
|
+
none yet, and that is the normal case when a script is launched by hand.
|
|
76
|
+
"""
|
|
77
|
+
return bool(self.url and self.token)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def effective_mode(self) -> str:
|
|
81
|
+
"""What a run will actually do: online without a server is disabled."""
|
|
82
|
+
if self.mode == "online" and not self.configured:
|
|
83
|
+
return "disabled"
|
|
84
|
+
return self.mode
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def read_settings(**overrides: Any) -> Settings:
|
|
88
|
+
"""Read the environment. Keyword arguments that are not ``None`` win over it."""
|
|
89
|
+
directory = os.getenv(ENV_DIR)
|
|
90
|
+
settings = Settings(
|
|
91
|
+
url=_first(ENV_URL),
|
|
92
|
+
token=_first(ENV_TOKEN),
|
|
93
|
+
training_uuid=_first(ENV_TRAINING_UUID),
|
|
94
|
+
project=_first(ENV_PROJECT),
|
|
95
|
+
# Verification stays on unless someone deliberately turns it off for a
|
|
96
|
+
# self-signed dev instance. The scripts this replaces disabled it
|
|
97
|
+
# globally and silenced the warning, which also disabled it in
|
|
98
|
+
# production.
|
|
99
|
+
verify_ssl=_flag(ENV_VERIFY, True),
|
|
100
|
+
mode=(os.getenv(ENV_MODE) or "online").strip().lower(),
|
|
101
|
+
directory=Path(directory).expanduser() if directory else default_directory(),
|
|
102
|
+
)
|
|
103
|
+
given = {key: value for key, value in overrides.items() if value is not None}
|
|
104
|
+
if "directory" in given:
|
|
105
|
+
given["directory"] = Path(given["directory"]).expanduser()
|
|
106
|
+
if "mode" in given:
|
|
107
|
+
given["mode"] = str(given["mode"]).strip().lower()
|
|
108
|
+
if given:
|
|
109
|
+
settings = replace(settings, **given)
|
|
110
|
+
if settings.mode not in MODES:
|
|
111
|
+
raise ConfigurationError(f"{ENV_MODE}={settings.mode!r}; expected one of: {', '.join(MODES)}")
|
|
112
|
+
return settings
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Showing visin's log lines on the console."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from typing import TextIO
|
|
8
|
+
|
|
9
|
+
_MARK = "_visin_console"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def enable_console_logging(level: int | str = logging.INFO, stream: TextIO | None = None) -> None:
|
|
13
|
+
"""Print visin's log lines: the run it created or resumed, anything it could
|
|
14
|
+
not send, and its summary at ``finish``.
|
|
15
|
+
|
|
16
|
+
visin logs through :mod:`logging` under the ``visin`` logger and, as a
|
|
17
|
+
library should, adds no handler of its own, so without this only warnings
|
|
18
|
+
reach the console. Pass ``stream=sys.stdout`` to keep the lines in order
|
|
19
|
+
with a script's own ``print`` output. Calling it again replaces the handler
|
|
20
|
+
rather than printing every line twice.
|
|
21
|
+
"""
|
|
22
|
+
logger = logging.getLogger("visin")
|
|
23
|
+
for handler in list(logger.handlers):
|
|
24
|
+
if getattr(handler, _MARK, False):
|
|
25
|
+
logger.removeHandler(handler)
|
|
26
|
+
handler = logging.StreamHandler(stream or sys.stderr)
|
|
27
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
28
|
+
setattr(handler, _MARK, True)
|
|
29
|
+
logger.addHandler(handler)
|
|
30
|
+
logger.setLevel(level)
|
|
31
|
+
logger.propagate = False
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""What a run writes, as data, and how each kind of write is delivered.
|
|
2
|
+
|
|
3
|
+
Every write a run makes is first an *op*: a small JSON-able dict naming its
|
|
4
|
+
kind and carrying its request body. A live run delivers ops as it goes; an
|
|
5
|
+
offline run, or a live one that lost the server, writes them to disk and
|
|
6
|
+
``visin sync`` delivers them later through this same function. One code path
|
|
7
|
+
means an op replayed a week later lands exactly as it would have at the time.
|
|
8
|
+
|
|
9
|
+
Delivery is made safe to repeat wherever the API allows it. A run, an epoch and
|
|
10
|
+
a test result carry a caller-generated UUID, so a repeat is answered 409 and
|
|
11
|
+
counted as delivered. Benchmarks and configs carry none, so those are the only
|
|
12
|
+
ops a lost answer can duplicate.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
from urllib.parse import quote
|
|
23
|
+
|
|
24
|
+
from ..errors import ApiError, ConfigurationError
|
|
25
|
+
from .transport import HttpClient
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("visin")
|
|
28
|
+
|
|
29
|
+
KINDS = frozenset({"create_run", "epoch", "test_result", "benchmark", "config", "visualization", "update"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class DeliveryContext:
|
|
34
|
+
"""What delivering one run's ops needs to remember between ops."""
|
|
35
|
+
|
|
36
|
+
files: Path | None = None
|
|
37
|
+
training_ids: dict[str, str] = field(default_factory=dict)
|
|
38
|
+
# Attempts per request, when a caller cannot afford the client's full
|
|
39
|
+
# budget: a run's blocking creation, or a catch-up that will be retried
|
|
40
|
+
# in a minute anyway. None means the client's own.
|
|
41
|
+
retries: int | None = None
|
|
42
|
+
# Runs a create_run found already registered: resumed, not new.
|
|
43
|
+
existing: set[str] = field(default_factory=set)
|
|
44
|
+
# Epochs the server answered 409 for: recorded before this delivery.
|
|
45
|
+
repeated: set[str] = field(default_factory=set)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def training_id(client: HttpClient, context: DeliveryContext, training_uuid: str) -> str:
|
|
49
|
+
"""The database id of a run, which ``PUT /trainings/:id`` needs."""
|
|
50
|
+
known = context.training_ids.get(training_uuid)
|
|
51
|
+
if known:
|
|
52
|
+
return known
|
|
53
|
+
found = client.request("GET", f"/trainings/uuid/{quote(training_uuid, safe='')}", retries=context.retries)
|
|
54
|
+
ident = (found or {}).get("_id")
|
|
55
|
+
if not ident:
|
|
56
|
+
raise ApiError(f"no training with uuid {training_uuid}", status=404)
|
|
57
|
+
context.training_ids[training_uuid] = ident
|
|
58
|
+
return str(ident)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _already_there(exc: ApiError) -> bool:
|
|
62
|
+
return exc.status == 409
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def deliver(client: HttpClient, op: dict[str, Any], context: DeliveryContext) -> Any:
|
|
66
|
+
kind = op.get("op")
|
|
67
|
+
body = op.get("body") or {}
|
|
68
|
+
if kind == "create_run":
|
|
69
|
+
return _create_run(client, body, context)
|
|
70
|
+
if kind == "epoch":
|
|
71
|
+
return _post_once(client, "/epochs/upload", body, context)
|
|
72
|
+
if kind == "test_result":
|
|
73
|
+
return _post_once(client, "/test-results/upload", body, context)
|
|
74
|
+
if kind == "benchmark":
|
|
75
|
+
return client.request("POST", "/benchmarks/upload", json=body, retries=context.retries)
|
|
76
|
+
if kind == "config":
|
|
77
|
+
return _config(client, op, body, context)
|
|
78
|
+
if kind == "visualization":
|
|
79
|
+
return _visualization(client, op, body, context)
|
|
80
|
+
if kind == "update":
|
|
81
|
+
ident = training_id(client, context, op["training_uuid"])
|
|
82
|
+
return client.request("PUT", f"/trainings/{ident}", json=body, retries=context.retries)
|
|
83
|
+
raise ValueError(f"unknown report kind {kind!r}; was it written by a newer visin?")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _post_once(client: HttpClient, path: str, body: dict[str, Any], context: DeliveryContext) -> Any:
|
|
87
|
+
try:
|
|
88
|
+
return client.request("POST", path, json=body, idempotent=True, retries=context.retries)
|
|
89
|
+
except ApiError as exc:
|
|
90
|
+
# The server refuses a repeat of a UUID it already has. That is exactly
|
|
91
|
+
# the outcome a retry should produce, so it counts as delivered.
|
|
92
|
+
if _already_there(exc):
|
|
93
|
+
logger.debug("visin: %s already recorded", path)
|
|
94
|
+
if body.get("epoch_uuid") and path == "/epochs/upload":
|
|
95
|
+
context.repeated.add(str(body["epoch_uuid"]))
|
|
96
|
+
return None
|
|
97
|
+
raise
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _create_run(client: HttpClient, body: dict[str, Any], context: DeliveryContext) -> Any:
|
|
101
|
+
uuid = body["uuid"]
|
|
102
|
+
try:
|
|
103
|
+
training = client.request("POST", "/trainings", json=body, idempotent=True, retries=context.retries)
|
|
104
|
+
except ApiError as exc:
|
|
105
|
+
if not _already_there(exc):
|
|
106
|
+
raise
|
|
107
|
+
# Created by an earlier attempt, or by whatever launched this process.
|
|
108
|
+
context.existing.add(uuid)
|
|
109
|
+
training = client.request("GET", f"/trainings/uuid/{quote(uuid, safe='')}", retries=context.retries)
|
|
110
|
+
ident = (training or {}).get("_id")
|
|
111
|
+
if ident:
|
|
112
|
+
context.training_ids[uuid] = str(ident)
|
|
113
|
+
return training
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _config(client: HttpClient, op: dict[str, Any], body: dict[str, Any], context: DeliveryContext) -> Any:
|
|
117
|
+
config = client.request("POST", "/configs/upload", json=body, retries=context.retries)
|
|
118
|
+
config_id = (config or {}).get("_id")
|
|
119
|
+
training_uuid = op.get("training_uuid")
|
|
120
|
+
if config_id and training_uuid:
|
|
121
|
+
# A config nobody points at is a row in a library. Linking it is what
|
|
122
|
+
# makes it this run's config, shown on the run's page.
|
|
123
|
+
ident = training_id(client, context, training_uuid)
|
|
124
|
+
client.request("PUT", f"/trainings/{ident}", json={"configId": config_id}, retries=context.retries)
|
|
125
|
+
return config
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _visualization(
|
|
129
|
+
client: HttpClient, op: dict[str, Any], body: dict[str, Any], context: DeliveryContext
|
|
130
|
+
) -> Any:
|
|
131
|
+
path = staged_path(op, context)
|
|
132
|
+
if not path.exists():
|
|
133
|
+
raise ConfigurationError(f"the file for visualization {body.get('filename')!r} is gone: {path}")
|
|
134
|
+
grant = client.request(
|
|
135
|
+
"POST",
|
|
136
|
+
"/visualizations/upload-url",
|
|
137
|
+
json={
|
|
138
|
+
"epoch_uuid": body["epoch_uuid"],
|
|
139
|
+
"filename": body["filename"],
|
|
140
|
+
"type": body["type"],
|
|
141
|
+
"mimetype": body["mimetype"],
|
|
142
|
+
},
|
|
143
|
+
retries=context.retries,
|
|
144
|
+
)
|
|
145
|
+
try:
|
|
146
|
+
upload_url, visualization_uuid, file_id = (
|
|
147
|
+
grant["uploadUrl"],
|
|
148
|
+
grant["visualization_uuid"],
|
|
149
|
+
grant["fileId"],
|
|
150
|
+
)
|
|
151
|
+
except (KeyError, TypeError) as exc:
|
|
152
|
+
raise ApiError(f"upload-url answered without {exc}", status=None) from exc
|
|
153
|
+
client.put_file(upload_url, str(path), body["mimetype"], retries=context.retries)
|
|
154
|
+
record = {
|
|
155
|
+
"epoch_uuid": body["epoch_uuid"],
|
|
156
|
+
"visualization_uuid": visualization_uuid,
|
|
157
|
+
"filename": body["filename"],
|
|
158
|
+
"type": body["type"],
|
|
159
|
+
"fileId": file_id,
|
|
160
|
+
"mimetype": body["mimetype"],
|
|
161
|
+
# The exact byte count: the server refuses a record whose size differs
|
|
162
|
+
# from the file it received.
|
|
163
|
+
"size": os.path.getsize(path),
|
|
164
|
+
}
|
|
165
|
+
if body.get("metadata") is not None:
|
|
166
|
+
record["metadata"] = body["metadata"]
|
|
167
|
+
return _post_once(client, "/visualizations", record, context)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def staged_path(op: dict[str, Any], context: DeliveryContext) -> Path:
|
|
171
|
+
"""Where the file an op uploads lives: its staged copy, or the original."""
|
|
172
|
+
staged = op.get("staged")
|
|
173
|
+
if staged and context.files is not None:
|
|
174
|
+
return context.files / str(staged)
|
|
175
|
+
return Path(str(op.get("path") or ""))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def discard_staged(op: dict[str, Any], context: DeliveryContext) -> None:
|
|
179
|
+
"""Remove an op's staged copy once nothing will need it again."""
|
|
180
|
+
if op.get("op") != "visualization" or not op.get("staged") or context.files is None:
|
|
181
|
+
return
|
|
182
|
+
try:
|
|
183
|
+
(context.files / op["staged"]).unlink()
|
|
184
|
+
except FileNotFoundError:
|
|
185
|
+
pass
|
|
186
|
+
except OSError as exc: # pragma: no cover - a read-only or vanished directory
|
|
187
|
+
logger.debug("visin: could not remove staged file: %s", exc)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""A background sender, so ``log_epoch`` costs the training loop nothing.
|
|
2
|
+
|
|
3
|
+
An epoch POST across a university network takes anywhere from 20ms to several
|
|
4
|
+
seconds, and it happens at exactly the moment the loop wants to start the next
|
|
5
|
+
epoch. Doing it inline makes reporting a tax on training time; worse, a
|
|
6
|
+
degraded Visin instance would slow the run down rather than merely losing
|
|
7
|
+
metrics.
|
|
8
|
+
|
|
9
|
+
So work is queued and a daemon thread drains it. The cost of that choice is
|
|
10
|
+
that failures surface late, which is why `flush` exists and why `Run.finish`
|
|
11
|
+
calls it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import queue
|
|
18
|
+
import threading
|
|
19
|
+
from typing import Callable
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("visin")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Barrier:
|
|
25
|
+
"""A marker the worker signals when it reaches this point in the queue."""
|
|
26
|
+
|
|
27
|
+
__slots__ = ("event",)
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self.event = threading.Event()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Sender:
|
|
34
|
+
"""Serialises queued work onto one thread.
|
|
35
|
+
|
|
36
|
+
One thread, not a pool: the writes are ordered (a run is created before its
|
|
37
|
+
epochs, an epoch exists before its visualizations) and ordering is cheaper
|
|
38
|
+
to guarantee than to recover from.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, max_queue: int = 10_000):
|
|
42
|
+
self._queue: queue.Queue[Callable[[], None] | _Barrier | None] = queue.Queue(maxsize=max_queue)
|
|
43
|
+
self._thread = threading.Thread(target=self._drain, name="visin-sender", daemon=True)
|
|
44
|
+
self._stopping = threading.Event()
|
|
45
|
+
self._abandoned = threading.Event()
|
|
46
|
+
self._started = False
|
|
47
|
+
self._lock = threading.Lock()
|
|
48
|
+
self.dropped = 0
|
|
49
|
+
self.failed = 0
|
|
50
|
+
# Work handed back by a stop that could not wait for it: never attempted.
|
|
51
|
+
self.leftover: list[Callable[[], None]] = []
|
|
52
|
+
|
|
53
|
+
def _ensure_started(self) -> None:
|
|
54
|
+
with self._lock:
|
|
55
|
+
if not self._started:
|
|
56
|
+
self._thread.start()
|
|
57
|
+
self._started = True
|
|
58
|
+
|
|
59
|
+
def submit(self, work: Callable[[], None]) -> bool:
|
|
60
|
+
"""Queue one unit of work. Never blocks the caller.
|
|
61
|
+
|
|
62
|
+
A full queue drops the item rather than blocking the training loop.
|
|
63
|
+
Losing a metric is a bad outcome; stalling training because a metrics
|
|
64
|
+
server is unreachable is a worse one, and the drop is counted so
|
|
65
|
+
`finish` can report it. Returns whether the work was queued.
|
|
66
|
+
"""
|
|
67
|
+
if self._stopping.is_set():
|
|
68
|
+
return False
|
|
69
|
+
self._ensure_started()
|
|
70
|
+
try:
|
|
71
|
+
self._queue.put_nowait(work)
|
|
72
|
+
except queue.Full:
|
|
73
|
+
self.dropped += 1
|
|
74
|
+
logger.warning("visin: queue full, dropped a report (%d dropped so far)", self.dropped)
|
|
75
|
+
return False
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
def _drain(self) -> None:
|
|
79
|
+
while True:
|
|
80
|
+
item = self._queue.get()
|
|
81
|
+
try:
|
|
82
|
+
if item is None:
|
|
83
|
+
return
|
|
84
|
+
if isinstance(item, _Barrier):
|
|
85
|
+
item.event.set()
|
|
86
|
+
continue
|
|
87
|
+
if self._abandoned.is_set():
|
|
88
|
+
self.leftover.append(item)
|
|
89
|
+
continue
|
|
90
|
+
item()
|
|
91
|
+
except Exception as exc: # noqa: BLE001 - a reporting failure must not kill the thread
|
|
92
|
+
self.failed += 1
|
|
93
|
+
logger.warning("visin: report failed: %s", exc)
|
|
94
|
+
finally:
|
|
95
|
+
self._queue.task_done()
|
|
96
|
+
|
|
97
|
+
def flush(self, timeout: float = 30.0) -> bool:
|
|
98
|
+
"""Block until everything queued so far has been attempted.
|
|
99
|
+
|
|
100
|
+
Implemented with a barrier rather than ``Queue.join`` because join has
|
|
101
|
+
no timeout, and a hung flush at the end of a training run would turn a
|
|
102
|
+
finished job into one the scheduler eventually kills.
|
|
103
|
+
"""
|
|
104
|
+
if not self._started or not self._thread.is_alive():
|
|
105
|
+
return True # nothing queued will ever run, so there is nothing to wait for
|
|
106
|
+
barrier = _Barrier()
|
|
107
|
+
try:
|
|
108
|
+
self._queue.put_nowait(barrier)
|
|
109
|
+
except queue.Full:
|
|
110
|
+
return False
|
|
111
|
+
if not barrier.event.wait(timeout):
|
|
112
|
+
logger.warning("visin: flush timed out after %.0fs; some reports may be unsent", timeout)
|
|
113
|
+
return False
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
def stop(self, timeout: float = 30.0) -> bool:
|
|
117
|
+
"""Flush, then retire the thread.
|
|
118
|
+
|
|
119
|
+
When the flush times out, whatever is still queued is not attempted: it
|
|
120
|
+
is moved to ``leftover``, so the caller can keep it for later rather
|
|
121
|
+
than lose it with the process. The item in flight, if any, finishes on
|
|
122
|
+
its own.
|
|
123
|
+
"""
|
|
124
|
+
flushed = self.flush(timeout)
|
|
125
|
+
self._stopping.set()
|
|
126
|
+
if not self._started:
|
|
127
|
+
return flushed
|
|
128
|
+
if not flushed:
|
|
129
|
+
self._abandoned.set()
|
|
130
|
+
while True:
|
|
131
|
+
try:
|
|
132
|
+
item = self._queue.get_nowait()
|
|
133
|
+
except queue.Empty:
|
|
134
|
+
break
|
|
135
|
+
if item is not None and not isinstance(item, _Barrier):
|
|
136
|
+
self.leftover.append(item)
|
|
137
|
+
self._queue.task_done()
|
|
138
|
+
try:
|
|
139
|
+
self._queue.put_nowait(None)
|
|
140
|
+
except queue.Full: # pragma: no cover - just drained
|
|
141
|
+
pass
|
|
142
|
+
self._thread.join(timeout=5.0 if flushed else 0.1)
|
|
143
|
+
return flushed
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Turning what a training loop has in hand into JSON Visin will accept.
|
|
2
|
+
|
|
3
|
+
A loop's metrics are rarely plain floats. They are NumPy scalars, zero-dimensional
|
|
4
|
+
tensors, the odd ``Decimal``; ``json.dumps`` refuses every one of them, and in
|
|
5
|
+
a background sender that refusal turns into a metric that silently never
|
|
6
|
+
arrives. Converting here, at the call, keeps the mistake visible and the
|
|
7
|
+
common cases working.
|
|
8
|
+
|
|
9
|
+
NaN and infinity have no JSON spelling. Sent anyway, the body is not valid JSON
|
|
10
|
+
and the server refuses the whole epoch, so they become ``null``, which Visin
|
|
11
|
+
already reads as "not measured" and charts as a gap.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import dataclasses
|
|
17
|
+
import enum
|
|
18
|
+
import math
|
|
19
|
+
import uuid
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from datetime import date, datetime
|
|
22
|
+
from decimal import Decimal
|
|
23
|
+
from pathlib import PurePath
|
|
24
|
+
from typing import Any, Callable
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def to_jsonable(
|
|
28
|
+
value: Any,
|
|
29
|
+
nonfinite: list[str] | None = None,
|
|
30
|
+
path: str = "",
|
|
31
|
+
fallback: Callable[[Any], Any] | None = None,
|
|
32
|
+
) -> Any:
|
|
33
|
+
"""Return ``value`` as plain JSON types.
|
|
34
|
+
|
|
35
|
+
``nonfinite``, when given, collects the dotted path of every NaN or
|
|
36
|
+
infinity that was replaced by ``None``, so the caller can say so.
|
|
37
|
+
``fallback`` converts what nothing else can; without it, that raises.
|
|
38
|
+
"""
|
|
39
|
+
if value is None or isinstance(value, (bool, str)):
|
|
40
|
+
return value
|
|
41
|
+
if isinstance(value, int):
|
|
42
|
+
return value
|
|
43
|
+
if isinstance(value, float):
|
|
44
|
+
if math.isfinite(value):
|
|
45
|
+
return value
|
|
46
|
+
if nonfinite is not None:
|
|
47
|
+
nonfinite.append(path or "<value>")
|
|
48
|
+
return None
|
|
49
|
+
if isinstance(value, Mapping):
|
|
50
|
+
return {
|
|
51
|
+
str(key): to_jsonable(item, nonfinite, f"{path}.{key}" if path else str(key), fallback)
|
|
52
|
+
for key, item in value.items()
|
|
53
|
+
}
|
|
54
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
55
|
+
return [
|
|
56
|
+
to_jsonable(item, nonfinite, f"{path}[{index}]", fallback) for index, item in enumerate(value)
|
|
57
|
+
]
|
|
58
|
+
if isinstance(value, (datetime, date)):
|
|
59
|
+
return value.isoformat()
|
|
60
|
+
if isinstance(value, Decimal):
|
|
61
|
+
return to_jsonable(float(value), nonfinite, path, fallback)
|
|
62
|
+
if isinstance(value, (uuid.UUID, PurePath)):
|
|
63
|
+
return str(value)
|
|
64
|
+
if isinstance(value, enum.Enum):
|
|
65
|
+
return to_jsonable(value.value, nonfinite, path, fallback)
|
|
66
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
67
|
+
return to_jsonable(dataclasses.asdict(value), nonfinite, path, fallback)
|
|
68
|
+
# NumPy scalars and arrays, and PyTorch/TensorFlow tensors, all offer
|
|
69
|
+
# ``tolist``: a scalar comes back as a Python number, an array as nested
|
|
70
|
+
# lists. Duck-typed so none of those libraries has to be installed.
|
|
71
|
+
tolist = getattr(value, "tolist", None)
|
|
72
|
+
if callable(tolist):
|
|
73
|
+
return to_jsonable(tolist(), nonfinite, path, fallback)
|
|
74
|
+
item = getattr(value, "item", None)
|
|
75
|
+
if callable(item):
|
|
76
|
+
return to_jsonable(item(), nonfinite, path, fallback)
|
|
77
|
+
numpy = getattr(value, "numpy", None) # an eager TensorFlow tensor
|
|
78
|
+
if callable(numpy):
|
|
79
|
+
return to_jsonable(numpy(), nonfinite, path, fallback)
|
|
80
|
+
if fallback is not None:
|
|
81
|
+
return fallback(value)
|
|
82
|
+
raise TypeError(
|
|
83
|
+
f"visin cannot send a {type(value).__name__}"
|
|
84
|
+
f"{f' (at {path})' if path else ''}; convert it to a number, string, list or dict"
|
|
85
|
+
)
|