expr_tracker 0.2.2__py3-none-any.whl → 0.2.3__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.
- expr_tracker/__init__.py +4 -0
- expr_tracker/cli.py +24 -0
- expr_tracker/history/__init__.py +13 -2
- expr_tracker/history/naming.py +74 -0
- expr_tracker/history/reader.py +40 -10
- expr_tracker/history/store.py +96 -28
- expr_tracker/run.py +27 -6
- expr_tracker/spans.py +236 -0
- expr_tracker/trace.py +189 -0
- expr_tracker/tracker.py +27 -5
- {expr_tracker-0.2.2.dist-info → expr_tracker-0.2.3.dist-info}/METADATA +6 -1
- {expr_tracker-0.2.2.dist-info → expr_tracker-0.2.3.dist-info}/RECORD +15 -12
- {expr_tracker-0.2.2.dist-info → expr_tracker-0.2.3.dist-info}/WHEEL +0 -0
- {expr_tracker-0.2.2.dist-info → expr_tracker-0.2.3.dist-info}/entry_points.txt +0 -0
- {expr_tracker-0.2.2.dist-info → expr_tracker-0.2.3.dist-info}/licenses/LICENSE +0 -0
expr_tracker/__init__.py
CHANGED
|
@@ -19,6 +19,7 @@ from .alerts import (
|
|
|
19
19
|
)
|
|
20
20
|
from .artifacts import Artifact
|
|
21
21
|
from .run import Run
|
|
22
|
+
from .spans import Span, span, start_span
|
|
22
23
|
|
|
23
24
|
try:
|
|
24
25
|
__version__ = _version("expr_tracker")
|
|
@@ -45,6 +46,7 @@ __all__ = [
|
|
|
45
46
|
"ChannelConfig",
|
|
46
47
|
"M",
|
|
47
48
|
"Run",
|
|
49
|
+
"Span",
|
|
48
50
|
"WebhookPolicy",
|
|
49
51
|
"__version__",
|
|
50
52
|
"add_alert_rule",
|
|
@@ -61,6 +63,8 @@ __all__ = [
|
|
|
61
63
|
"log_artifact",
|
|
62
64
|
"register_backend",
|
|
63
65
|
"remove_alert_rule",
|
|
66
|
+
"span",
|
|
67
|
+
"start_span",
|
|
64
68
|
"summary",
|
|
65
69
|
"use_artifact",
|
|
66
70
|
]
|
expr_tracker/cli.py
CHANGED
|
@@ -13,6 +13,7 @@ from .alerts.engine import AlertEngine
|
|
|
13
13
|
from .alerts.expr import EvalContext, compile_condition, parse_rule, validate
|
|
14
14
|
from .alerts.models import AlertConfig, ChannelConfig, WebhookPolicy
|
|
15
15
|
from .history import MetricSeries, read_history
|
|
16
|
+
from .trace import write_trace
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
@click.group()
|
|
@@ -54,6 +55,29 @@ def history(run: str, n: int, metrics: str | None, step_range: str | None, fmt:
|
|
|
54
55
|
click.echo(_to_table(rows))
|
|
55
56
|
|
|
56
57
|
|
|
58
|
+
@main.command()
|
|
59
|
+
@click.argument("run", type=click.Path(exists=True))
|
|
60
|
+
@click.option("-o", "--output", default="trace.json", help="Where to write the trace")
|
|
61
|
+
@click.option(
|
|
62
|
+
"--stream",
|
|
63
|
+
default=None,
|
|
64
|
+
help="Comma separated streams; every stream by default. Use 'default' for the "
|
|
65
|
+
"unnamed one.",
|
|
66
|
+
)
|
|
67
|
+
@click.option("--step-range", default=None, help="start:end (end exclusive)")
|
|
68
|
+
def trace(run: str, output: str, stream: str | None, step_range: str | None):
|
|
69
|
+
"""Export recorded spans as a Chrome Trace.
|
|
70
|
+
|
|
71
|
+
Open the result at https://ui.perfetto.dev or chrome://tracing. It can be
|
|
72
|
+
loaded beside a torch.profiler trace.
|
|
73
|
+
"""
|
|
74
|
+
wanted: str | list[str] | None = "*"
|
|
75
|
+
if stream:
|
|
76
|
+
wanted = [None if s in ("default", "") else s for s in stream.split(",")]
|
|
77
|
+
count = write_trace(run, output, wanted, step_range=_parse_range(step_range))
|
|
78
|
+
click.echo(f"wrote {count} span(s) to {output}")
|
|
79
|
+
|
|
80
|
+
|
|
57
81
|
@main.group()
|
|
58
82
|
def rules():
|
|
59
83
|
"""Alert rule tooling."""
|
expr_tracker/history/__init__.py
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
from .codec import RecordCodec
|
|
2
2
|
from .frame import project, to_output
|
|
3
|
-
from .
|
|
3
|
+
from .naming import current_rank, metrics_filename, parse_stream, validate_stream
|
|
4
|
+
from .reader import (
|
|
5
|
+
JsonlReader,
|
|
6
|
+
list_streams,
|
|
7
|
+
merge_steps,
|
|
8
|
+
read_history,
|
|
9
|
+
resolve_run_path,
|
|
10
|
+
)
|
|
4
11
|
from .series import MetricSeries
|
|
5
|
-
from .store import HistoryStore,
|
|
12
|
+
from .store import HistoryStore, resolve_commit
|
|
6
13
|
from .writer import JsonlWriter
|
|
7
14
|
|
|
8
15
|
__all__ = [
|
|
@@ -12,10 +19,14 @@ __all__ = [
|
|
|
12
19
|
"MetricSeries",
|
|
13
20
|
"RecordCodec",
|
|
14
21
|
"current_rank",
|
|
22
|
+
"list_streams",
|
|
15
23
|
"merge_steps",
|
|
24
|
+
"metrics_filename",
|
|
25
|
+
"parse_stream",
|
|
16
26
|
"project",
|
|
17
27
|
"read_history",
|
|
18
28
|
"resolve_commit",
|
|
19
29
|
"resolve_run_path",
|
|
20
30
|
"to_output",
|
|
31
|
+
"validate_stream",
|
|
21
32
|
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Run file naming: distributed rank shards and independent producer streams.
|
|
2
|
+
|
|
3
|
+
Kept separate from the store and the reader because both need it, and the store
|
|
4
|
+
already depends on the reader.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
STREAM_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
|
13
|
+
RANK_PATTERN = re.compile(r"rank\d+")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def current_rank() -> int:
|
|
17
|
+
"""Distributed rank from the usual environment variables, 0 when unset."""
|
|
18
|
+
rank = os.getenv("RANK") or os.getenv("LOCAL_RANK") or "0"
|
|
19
|
+
try:
|
|
20
|
+
return int(rank)
|
|
21
|
+
except ValueError:
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_stream(stream: str) -> str:
|
|
26
|
+
"""Check a stream name is safe as a filename component.
|
|
27
|
+
|
|
28
|
+
The name becomes part of ``metrics.<stream>[.rankN].jsonl``, so it must not
|
|
29
|
+
contain a separator, and must not look like a rank shard.
|
|
30
|
+
"""
|
|
31
|
+
if not STREAM_PATTERN.match(stream):
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"Invalid stream name {stream!r}; use letters, digits, '_' or '-', "
|
|
34
|
+
"starting with a letter or digit."
|
|
35
|
+
)
|
|
36
|
+
if RANK_PATTERN.fullmatch(stream):
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"Stream name {stream!r} collides with the rank shard naming; "
|
|
39
|
+
"pick another name."
|
|
40
|
+
)
|
|
41
|
+
return stream
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def metrics_filename(stream: str | None, rank_aware: bool = True) -> str:
|
|
45
|
+
"""``metrics[.stream][.rankN].jsonl``.
|
|
46
|
+
|
|
47
|
+
Streams separate independent producers; non-zero ranks get their own shard so
|
|
48
|
+
concurrent appends cannot interleave. The two compose.
|
|
49
|
+
"""
|
|
50
|
+
rank = current_rank() if rank_aware else 0
|
|
51
|
+
parts = ["metrics"]
|
|
52
|
+
if stream:
|
|
53
|
+
parts.append(stream)
|
|
54
|
+
if rank > 0:
|
|
55
|
+
parts.append(f"rank{rank}")
|
|
56
|
+
return ".".join(parts) + ".jsonl"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def spans_filename(stream: str | None, rank_aware: bool = True) -> str:
|
|
60
|
+
"""``spans[.stream][.rankN].jsonl``, alongside the metrics file."""
|
|
61
|
+
return metrics_filename(stream, rank_aware).replace("metrics", "spans", 1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def sidecar_filename(base: str, stream: str | None, suffix: str) -> str:
|
|
65
|
+
"""``<base>[.stream].<suffix>``, so producers do not overwrite each other."""
|
|
66
|
+
return f"{base}.{stream}.{suffix}" if stream else f"{base}.{suffix}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_stream(filename: str) -> str | None:
|
|
70
|
+
"""The stream a metrics filename belongs to; ``None`` is the default producer."""
|
|
71
|
+
parts = filename.split(".")[1:-1] # drop "metrics" and "jsonl"
|
|
72
|
+
if parts and RANK_PATTERN.fullmatch(parts[-1]):
|
|
73
|
+
parts = parts[:-1]
|
|
74
|
+
return parts[0] if parts else None
|
expr_tracker/history/reader.py
CHANGED
|
@@ -14,6 +14,7 @@ from pathlib import Path
|
|
|
14
14
|
from loguru import logger
|
|
15
15
|
|
|
16
16
|
from .frame import project, to_output
|
|
17
|
+
from .naming import RANK_PATTERN, metrics_filename
|
|
17
18
|
|
|
18
19
|
CHUNK_SIZE = 256 * 1024
|
|
19
20
|
|
|
@@ -317,6 +318,7 @@ def read_history(
|
|
|
317
318
|
run: str | Path,
|
|
318
319
|
n: int | None = 50,
|
|
319
320
|
*,
|
|
321
|
+
stream: str | None = None,
|
|
320
322
|
output_type: str = "dict",
|
|
321
323
|
metrics: Sequence[str] | None = None,
|
|
322
324
|
step_range: tuple[int | None, int | None] | None = None,
|
|
@@ -325,7 +327,7 @@ def read_history(
|
|
|
325
327
|
dropna: bool = False,
|
|
326
328
|
):
|
|
327
329
|
"""Read any run's history without ``init()``; ``run`` is a directory or file."""
|
|
328
|
-
reader = JsonlReader(resolve_run_path(run))
|
|
330
|
+
reader = JsonlReader(resolve_run_path(run, stream))
|
|
329
331
|
limit = None if n is None or n < 0 else max(0, n)
|
|
330
332
|
if limit == 0:
|
|
331
333
|
records: list[dict] = []
|
|
@@ -347,13 +349,41 @@ def read_history(
|
|
|
347
349
|
return to_output(rows, output_type)
|
|
348
350
|
|
|
349
351
|
|
|
350
|
-
def resolve_run_path(run: str | Path) -> Path:
|
|
352
|
+
def resolve_run_path(run: str | Path, stream: str | None = None) -> Path:
|
|
353
|
+
"""The metrics file of a run directory, or the file itself.
|
|
354
|
+
|
|
355
|
+
Names are matched exactly rather than by sort order: ``metrics.data.jsonl``
|
|
356
|
+
sorts before ``metrics.jsonl``, so picking the first match would silently
|
|
357
|
+
return a stream instead of the default producer.
|
|
358
|
+
"""
|
|
351
359
|
path = Path(run)
|
|
352
|
-
if path.is_dir():
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
+
if not path.is_dir():
|
|
361
|
+
if not path.exists():
|
|
362
|
+
raise FileNotFoundError(f"Run path does not exist: {path}")
|
|
363
|
+
return path
|
|
364
|
+
wanted = metrics_filename(stream, rank_aware=False)
|
|
365
|
+
exact = path / wanted
|
|
366
|
+
if exact.is_file():
|
|
367
|
+
return exact
|
|
368
|
+
# Fall back to this stream's rank shards, which a worker rank writes instead
|
|
369
|
+
prefix = wanted[: -len(".jsonl")]
|
|
370
|
+
shards = sorted(path.glob(f"{prefix}.rank*.jsonl"))
|
|
371
|
+
if shards:
|
|
372
|
+
return shards[0]
|
|
373
|
+
raise FileNotFoundError(f"No {wanted} found under {path}; {_available(path)}")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _available(path: Path) -> str:
|
|
377
|
+
found = sorted(p.name for p in path.glob("metrics*.jsonl"))
|
|
378
|
+
return f"available: {', '.join(found)}" if found else "the directory has none"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def list_streams(run: str | Path) -> list[str | None]:
|
|
382
|
+
"""Every stream in a run directory; ``None`` is the default producer."""
|
|
383
|
+
streams: set[str | None] = set()
|
|
384
|
+
for path in Path(run).glob("metrics*.jsonl"):
|
|
385
|
+
parts = path.name.split(".")[1:-1] # drop "metrics" and "jsonl"
|
|
386
|
+
if parts and RANK_PATTERN.fullmatch(parts[-1]):
|
|
387
|
+
parts = parts[:-1]
|
|
388
|
+
streams.add(parts[0] if parts else None)
|
|
389
|
+
return sorted(streams, key=lambda s: (s is not None, s or ""))
|
expr_tracker/history/store.py
CHANGED
|
@@ -13,7 +13,6 @@ from __future__ import annotations
|
|
|
13
13
|
import atexit
|
|
14
14
|
import json
|
|
15
15
|
import operator
|
|
16
|
-
import os
|
|
17
16
|
import threading
|
|
18
17
|
import time
|
|
19
18
|
from collections import deque
|
|
@@ -25,6 +24,12 @@ from loguru import logger
|
|
|
25
24
|
|
|
26
25
|
from .codec import RESERVED_KEYS, RecordCodec, encode_line
|
|
27
26
|
from .frame import project, to_output
|
|
27
|
+
from .naming import (
|
|
28
|
+
metrics_filename,
|
|
29
|
+
sidecar_filename,
|
|
30
|
+
spans_filename,
|
|
31
|
+
validate_stream,
|
|
32
|
+
)
|
|
28
33
|
from .reader import JsonlReader, merge_steps
|
|
29
34
|
from .series import DEFAULT_WINDOW, MetricSeries
|
|
30
35
|
from .writer import (
|
|
@@ -65,6 +70,10 @@ class HistoryOptions:
|
|
|
65
70
|
max_open_seconds: float | None = DEFAULT_MAX_OPEN_SECONDS
|
|
66
71
|
step_policy: str = "monotonic"
|
|
67
72
|
rank_aware: bool = True
|
|
73
|
+
# an independent producer writing its own file with its own step cursor
|
|
74
|
+
stream: str | None = None
|
|
75
|
+
# write the full span tree to spans.jsonl beside the metrics
|
|
76
|
+
spans: bool = True
|
|
68
77
|
# alerts and output
|
|
69
78
|
alert_window: int = DEFAULT_WINDOW
|
|
70
79
|
print_to_screen: bool = False
|
|
@@ -76,6 +85,8 @@ class HistoryOptions:
|
|
|
76
85
|
f"Unknown step_policy {self.step_policy!r}; "
|
|
77
86
|
f"expected one of {', '.join(STEP_POLICIES)}."
|
|
78
87
|
)
|
|
88
|
+
if self.stream is not None:
|
|
89
|
+
object.__setattr__(self, "stream", validate_stream(self.stream))
|
|
79
90
|
clamped = {
|
|
80
91
|
"cache_bytes": max(0, int(self.cache_bytes)),
|
|
81
92
|
"cache_rows": max(1, int(self.cache_rows)),
|
|
@@ -128,6 +139,9 @@ class HistoryStore:
|
|
|
128
139
|
self.log_dir: Path | None = None
|
|
129
140
|
self.log_fp: Path | None = None
|
|
130
141
|
self.config_fp: Path | None = None
|
|
142
|
+
self.spans_fp: Path | None = None
|
|
143
|
+
self.span_writer: JsonlWriter | None = None
|
|
144
|
+
self.stream: str | None = None
|
|
131
145
|
self.project: str = ""
|
|
132
146
|
self.name: str = ""
|
|
133
147
|
self._reset(HistoryOptions(), on_commit=None)
|
|
@@ -177,12 +191,29 @@ class HistoryStore:
|
|
|
177
191
|
self.project, self.name = project, name
|
|
178
192
|
self.log_dir = Path(dir or "./tracker/jsonl") / project / name
|
|
179
193
|
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
180
|
-
self.
|
|
181
|
-
self.
|
|
194
|
+
self.stream = opts.stream
|
|
195
|
+
self.config_fp = self.log_dir / sidecar_filename("config", opts.stream, "json")
|
|
196
|
+
self.spans_fp = (
|
|
197
|
+
self.log_dir / spans_filename(opts.stream, opts.rank_aware)
|
|
198
|
+
if opts.spans
|
|
199
|
+
else None
|
|
200
|
+
)
|
|
201
|
+
self.log_fp = self.log_dir / metrics_filename(opts.stream, opts.rank_aware)
|
|
182
202
|
|
|
183
203
|
self._close_previous()
|
|
184
204
|
self._reset(opts, on_commit)
|
|
185
205
|
self.writer = self._open_writer(opts)
|
|
206
|
+
self.span_writer = (
|
|
207
|
+
JsonlWriter(
|
|
208
|
+
self.spans_fp,
|
|
209
|
+
buffer_size=opts.buffer_size,
|
|
210
|
+
buffer_interval=opts.buffer_interval,
|
|
211
|
+
max_buffer_seconds=opts.max_buffer_seconds,
|
|
212
|
+
max_pending_records=opts.max_pending_records,
|
|
213
|
+
)
|
|
214
|
+
if self.spans_fp is not None
|
|
215
|
+
else None
|
|
216
|
+
)
|
|
186
217
|
self._resume(opts.alert_window)
|
|
187
218
|
self._write_config(config)
|
|
188
219
|
self._register_atexit()
|
|
@@ -191,8 +222,9 @@ class HistoryStore:
|
|
|
191
222
|
def _close_previous(self):
|
|
192
223
|
"""Re-initialising must not strand the previous writer's buffer or timer."""
|
|
193
224
|
self._cancel_open_timer()
|
|
194
|
-
|
|
195
|
-
|
|
225
|
+
for writer in (self.writer, self.span_writer):
|
|
226
|
+
if writer is not None:
|
|
227
|
+
writer.close()
|
|
196
228
|
|
|
197
229
|
def _open_writer(self, options: HistoryOptions) -> JsonlWriter:
|
|
198
230
|
assert self.log_fp is not None
|
|
@@ -276,7 +308,24 @@ class HistoryStore:
|
|
|
276
308
|
``is None``, since step 0 is falsy.
|
|
277
309
|
"""
|
|
278
310
|
self._require_init()
|
|
279
|
-
|
|
311
|
+
return self.ingest(self._codec.encode(metrics), step=step, commit=commit)
|
|
312
|
+
|
|
313
|
+
def ingest(
|
|
314
|
+
self,
|
|
315
|
+
encoded: dict,
|
|
316
|
+
*,
|
|
317
|
+
step: int | None = None,
|
|
318
|
+
commit: bool | None = None,
|
|
319
|
+
accumulate: bool = False,
|
|
320
|
+
) -> int | None:
|
|
321
|
+
"""The single path metrics take into the open row.
|
|
322
|
+
|
|
323
|
+
``log()`` and span durations both come through here, so the step policy,
|
|
324
|
+
the merge and the commit rules only exist once. ``accumulate`` adds to any
|
|
325
|
+
existing value instead of replacing it, which is what repeated spans in
|
|
326
|
+
one step need.
|
|
327
|
+
"""
|
|
328
|
+
self._require_init()
|
|
280
329
|
now = time.time()
|
|
281
330
|
pending: list[dict] = []
|
|
282
331
|
with self._lock:
|
|
@@ -287,7 +336,7 @@ class HistoryStore:
|
|
|
287
336
|
return None
|
|
288
337
|
pending.append(self._switch_open_row(step, now))
|
|
289
338
|
resolved = self._open_step
|
|
290
|
-
self._update_open_row(encoded)
|
|
339
|
+
self._update_open_row(encoded, accumulate)
|
|
291
340
|
if resolve_commit(step, commit):
|
|
292
341
|
pending.append(self._close_open_row())
|
|
293
342
|
for record in filter(None, pending):
|
|
@@ -295,6 +344,28 @@ class HistoryStore:
|
|
|
295
344
|
self._schedule_open_timer()
|
|
296
345
|
return resolved
|
|
297
346
|
|
|
347
|
+
def record_span(self, metrics: dict, record: dict | None = None) -> int | None:
|
|
348
|
+
"""A finished span: durations join the open row, the tree goes to disk.
|
|
349
|
+
|
|
350
|
+
Durations never commit a step of their own -- they ride along with
|
|
351
|
+
whatever ``log()`` commits, so a span costs no extra row. The metrics are
|
|
352
|
+
built from floats and ints here, so they skip the encoder: spans are
|
|
353
|
+
recorded per sub-step and cannot afford it.
|
|
354
|
+
"""
|
|
355
|
+
step = self.ingest(metrics, commit=False, accumulate=True)
|
|
356
|
+
if record is None or self.span_writer is None or step is None:
|
|
357
|
+
return step
|
|
358
|
+
try:
|
|
359
|
+
line = encode_line({"_step": step, **record})
|
|
360
|
+
except Exception as e:
|
|
361
|
+
logger.warning(f"Dropping span that cannot be serialized: {e}")
|
|
362
|
+
return step
|
|
363
|
+
# enqueue, not append: the batch goes out when the step commits, so a
|
|
364
|
+
# span does not pay for a flush decision of its own
|
|
365
|
+
if self.span_writer.enqueue(step, self.span_writer.lines, line):
|
|
366
|
+
self.span_writer.flush()
|
|
367
|
+
return step
|
|
368
|
+
|
|
298
369
|
def _switch_open_row(self, step: int | None, now: float) -> dict | None:
|
|
299
370
|
"""Point the open row at ``step``, returning the row it displaced, if any.
|
|
300
371
|
|
|
@@ -331,12 +402,18 @@ class HistoryStore:
|
|
|
331
402
|
if timer is not None:
|
|
332
403
|
timer.cancel()
|
|
333
404
|
|
|
334
|
-
def _update_open_row(self, encoded: dict):
|
|
405
|
+
def _update_open_row(self, encoded: dict, accumulate: bool = False):
|
|
335
406
|
for key in RESERVED_KEYS & set(encoded):
|
|
336
407
|
self._codec.warn_once(
|
|
337
408
|
key, f"Metric name {key!r} is reserved and will be ignored."
|
|
338
409
|
)
|
|
339
410
|
encoded.pop(key)
|
|
411
|
+
if accumulate:
|
|
412
|
+
# Repeated spans in one step sum rather than overwrite
|
|
413
|
+
for key, value in encoded.items():
|
|
414
|
+
current = self._open_row.get(key)
|
|
415
|
+
self._open_row[key] = value + current if _addable(current) else value
|
|
416
|
+
return
|
|
340
417
|
for key in encoded:
|
|
341
418
|
if key in self._open_row:
|
|
342
419
|
self._codec.warn_once(
|
|
@@ -480,8 +557,9 @@ class HistoryStore:
|
|
|
480
557
|
record = self._close_open_row()
|
|
481
558
|
if record is not None:
|
|
482
559
|
self._emit(record)
|
|
483
|
-
|
|
484
|
-
|
|
560
|
+
for writer in (self.writer, self.span_writer):
|
|
561
|
+
if writer is not None:
|
|
562
|
+
writer.flush()
|
|
485
563
|
|
|
486
564
|
def finish(self):
|
|
487
565
|
"""Close the store. Never raises: a bad disk must not mask the real error."""
|
|
@@ -492,11 +570,12 @@ class HistoryStore:
|
|
|
492
570
|
self.flush(commit_open=True)
|
|
493
571
|
except Exception as e:
|
|
494
572
|
logger.warning(f"Failed to flush history on finish: {e}")
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
573
|
+
for writer in (self.writer, self.span_writer):
|
|
574
|
+
if writer is not None:
|
|
575
|
+
try:
|
|
576
|
+
writer.close()
|
|
577
|
+
except Exception as e:
|
|
578
|
+
logger.warning(f"Failed to close {writer.path}: {e}")
|
|
500
579
|
if self._atexit_registered:
|
|
501
580
|
atexit.unregister(self._atexit)
|
|
502
581
|
self._atexit_registered = False
|
|
@@ -758,19 +837,8 @@ class HistoryStore:
|
|
|
758
837
|
# ---------------------------------------------------------------------- helpers
|
|
759
838
|
|
|
760
839
|
|
|
761
|
-
def
|
|
762
|
-
|
|
763
|
-
rank = os.getenv("RANK") or os.getenv("LOCAL_RANK") or "0"
|
|
764
|
-
try:
|
|
765
|
-
return int(rank)
|
|
766
|
-
except ValueError:
|
|
767
|
-
return 0
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
def _metrics_filename(rank_aware: bool) -> str:
|
|
771
|
-
"""Non-zero ranks get their own shard so appends cannot interleave."""
|
|
772
|
-
rank = current_rank() if rank_aware else 0
|
|
773
|
-
return "metrics.jsonl" if rank <= 0 else f"metrics.rank{rank}.jsonl"
|
|
840
|
+
def _addable(value) -> bool:
|
|
841
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
774
842
|
|
|
775
843
|
|
|
776
844
|
def _parse_cached(entries: Sequence[tuple[int, int, bytes]]) -> list[dict]:
|
expr_tracker/run.py
CHANGED
|
@@ -17,6 +17,7 @@ from loguru import logger
|
|
|
17
17
|
|
|
18
18
|
from .artifacts import Artifact, ArtifactStore, coerce_artifact
|
|
19
19
|
from .history import HistoryStore, current_rank, resolve_commit
|
|
20
|
+
from .history.naming import sidecar_filename
|
|
20
21
|
from .summary import Summary
|
|
21
22
|
|
|
22
23
|
_lock = threading.RLock()
|
|
@@ -72,6 +73,7 @@ class Run:
|
|
|
72
73
|
alert: Any = None,
|
|
73
74
|
alert_rules: Sequence[Any] = (),
|
|
74
75
|
alert_on_rank: int | None = 0,
|
|
76
|
+
stream: str | None = None,
|
|
75
77
|
**history_kwargs,
|
|
76
78
|
):
|
|
77
79
|
self.project = project
|
|
@@ -96,11 +98,15 @@ class Run:
|
|
|
96
98
|
dir=dir,
|
|
97
99
|
print_to_screen=print_to_screen,
|
|
98
100
|
on_commit=self._on_commit,
|
|
101
|
+
stream=stream,
|
|
99
102
|
**{**backend_kwargs.get("jsonl", {}), **history_kwargs},
|
|
100
103
|
)
|
|
101
104
|
self._closers.append(self.history.finish)
|
|
102
105
|
self.name = self.history.name
|
|
103
|
-
self.
|
|
106
|
+
self.stream = self.history.stream
|
|
107
|
+
self.summary = Summary(
|
|
108
|
+
self.history.log_dir / sidecar_filename("summary", self.stream, "json")
|
|
109
|
+
)
|
|
104
110
|
# History survives a crash through its own atexit hook; the summary is
|
|
105
111
|
# just as much a record of the run, so persist it the same way
|
|
106
112
|
atexit.register(self._save_summary_at_exit)
|
|
@@ -150,9 +156,20 @@ class Run:
|
|
|
150
156
|
self._rollback()
|
|
151
157
|
raise
|
|
152
158
|
|
|
159
|
+
@property
|
|
160
|
+
def backend_run_name(self) -> str:
|
|
161
|
+
"""A stream is an independent producer, so it gets its own backend run.
|
|
162
|
+
|
|
163
|
+
Grouping ties them back together: both wandb and trackio understand
|
|
164
|
+
``group``, and neither can merge two step axes into one run.
|
|
165
|
+
"""
|
|
166
|
+
return f"{self.name}-{self.stream}" if self.stream else self.name
|
|
167
|
+
|
|
153
168
|
def _init_backend(
|
|
154
169
|
self, name, instance, *, entity, dir, notes, tags, resume, kwargs
|
|
155
170
|
):
|
|
171
|
+
# A grouped run keeps the streams together in the backend UI
|
|
172
|
+
grouping = {"group": self.name} if self.stream else {}
|
|
156
173
|
try:
|
|
157
174
|
if name == "trackio":
|
|
158
175
|
# trackio.init has no entity/notes/tags/dir/id, so those ride along
|
|
@@ -165,23 +182,27 @@ class Run:
|
|
|
165
182
|
}
|
|
166
183
|
instance.init(
|
|
167
184
|
project=self.project,
|
|
168
|
-
name=self.
|
|
185
|
+
name=self.backend_run_name,
|
|
169
186
|
config=config,
|
|
170
187
|
resume=_trackio_resume(resume),
|
|
171
|
-
**kwargs,
|
|
188
|
+
**{**grouping, **kwargs},
|
|
172
189
|
)
|
|
173
190
|
else:
|
|
174
191
|
instance.init(
|
|
175
192
|
project=self.project,
|
|
176
|
-
name=self.
|
|
193
|
+
name=self.backend_run_name,
|
|
177
194
|
entity=entity,
|
|
178
195
|
dir=dir,
|
|
179
196
|
notes=notes,
|
|
180
197
|
tags=tags,
|
|
181
198
|
resume=resume,
|
|
182
|
-
id=self.
|
|
199
|
+
id=self.backend_run_name,
|
|
183
200
|
config=self.config,
|
|
184
|
-
**
|
|
201
|
+
**{
|
|
202
|
+
**grouping,
|
|
203
|
+
**({"job_type": self.stream} if self.stream else {}),
|
|
204
|
+
**kwargs,
|
|
205
|
+
},
|
|
185
206
|
)
|
|
186
207
|
except Exception as e:
|
|
187
208
|
logger.warning(f"Failed to initialize backend {name}: {e}")
|
expr_tracker/spans.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Spans: how long each part of a step took, and how the parts nest.
|
|
2
|
+
|
|
3
|
+
A closed span adds its duration to the open row, so it becomes an ordinary metric
|
|
4
|
+
that ``history()``, alert rules and plots already understand. The full tree, with
|
|
5
|
+
timestamps and attributes, goes to ``spans.jsonl`` for trace viewers.
|
|
6
|
+
|
|
7
|
+
Durations never commit a step of their own; they ride along with whatever
|
|
8
|
+
``log()`` commits.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import functools
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from contextvars import ContextVar
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from loguru import logger
|
|
20
|
+
|
|
21
|
+
# Per-thread and per-task by construction: a new thread starts from the default,
|
|
22
|
+
# and each asyncio task gets its own copy, which is exactly span nesting.
|
|
23
|
+
_STACK: ContextVar[tuple[Span, ...]] = ContextVar("et_span_stack", default=())
|
|
24
|
+
|
|
25
|
+
DURATION_SUFFIX = "duration_ms"
|
|
26
|
+
COUNT_SUFFIX = "count"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def current_span() -> Span | None:
|
|
30
|
+
stack = _STACK.get()
|
|
31
|
+
return stack[-1] if stack else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def active_path() -> str:
|
|
35
|
+
"""The ``/`` joined names of the spans currently open on this thread."""
|
|
36
|
+
return "/".join(span.name for span in _STACK.get())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Span:
|
|
40
|
+
"""One timed region. Prefer :func:`span`; construct it directly only when
|
|
41
|
+
you need to end it somewhere else."""
|
|
42
|
+
|
|
43
|
+
__slots__ = (
|
|
44
|
+
"_ended",
|
|
45
|
+
"_store",
|
|
46
|
+
"_token",
|
|
47
|
+
"attributes",
|
|
48
|
+
"duration_ms",
|
|
49
|
+
"error",
|
|
50
|
+
"name",
|
|
51
|
+
"path",
|
|
52
|
+
"start",
|
|
53
|
+
"started_at",
|
|
54
|
+
"track",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def __init__(self, name: str, store, **attributes: Any):
|
|
58
|
+
self.name = str(name).strip("/") or "span"
|
|
59
|
+
self.attributes = dict(attributes)
|
|
60
|
+
self._store = store
|
|
61
|
+
self.path = self.name
|
|
62
|
+
self.track = 0
|
|
63
|
+
self.start = 0.0
|
|
64
|
+
self.started_at = 0.0
|
|
65
|
+
self.duration_ms = 0.0
|
|
66
|
+
self.error: str | None = None
|
|
67
|
+
self._token = None
|
|
68
|
+
self._ended = False
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------------ lifecycle
|
|
71
|
+
|
|
72
|
+
def begin(self) -> Span:
|
|
73
|
+
parent = current_span()
|
|
74
|
+
self.path = f"{parent.path}/{self.name}" if parent else self.name
|
|
75
|
+
# Children share their parent's track, so a tree never straddles lanes
|
|
76
|
+
self.track = parent.track if parent else threading.get_ident()
|
|
77
|
+
self.started_at = time.time()
|
|
78
|
+
self.start = time.perf_counter()
|
|
79
|
+
self._token = _STACK.set((*_STACK.get(), self))
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def end(self, error: BaseException | None = None) -> float:
|
|
83
|
+
"""Close the span and record it. Returns the duration in milliseconds."""
|
|
84
|
+
if self._ended:
|
|
85
|
+
return self.duration_ms
|
|
86
|
+
self._ended = True
|
|
87
|
+
self.duration_ms = (time.perf_counter() - self.start) * 1000.0
|
|
88
|
+
if error is not None:
|
|
89
|
+
self.error = type(error).__name__
|
|
90
|
+
if self._token is not None:
|
|
91
|
+
try:
|
|
92
|
+
_STACK.reset(self._token)
|
|
93
|
+
except ValueError: # pragma: no cover - ended on another thread/task
|
|
94
|
+
_STACK.set(tuple(s for s in _STACK.get() if s is not self))
|
|
95
|
+
self._record()
|
|
96
|
+
return self.duration_ms
|
|
97
|
+
|
|
98
|
+
def set(self, **attributes: Any) -> Span:
|
|
99
|
+
"""Attach attributes; they reach ``spans.jsonl``, not the metrics."""
|
|
100
|
+
self.attributes.update(attributes)
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------ context
|
|
104
|
+
|
|
105
|
+
def __enter__(self) -> Span:
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def __exit__(self, kind, value, traceback) -> bool:
|
|
109
|
+
self.end(value)
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
async def __aenter__(self) -> Span:
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
async def __aexit__(self, kind, value, traceback) -> bool:
|
|
116
|
+
self.end(value)
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
120
|
+
state = f"{self.duration_ms:.3f}ms" if self._ended else "open"
|
|
121
|
+
return f"Span({self.path}, {state})"
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------ output
|
|
124
|
+
|
|
125
|
+
def _record(self):
|
|
126
|
+
if self._store is None:
|
|
127
|
+
return
|
|
128
|
+
metrics = {
|
|
129
|
+
f"{self.path}/{DURATION_SUFFIX}": self.duration_ms,
|
|
130
|
+
f"{self.path}/{COUNT_SUFFIX}": 1,
|
|
131
|
+
}
|
|
132
|
+
record = {
|
|
133
|
+
"name": self.path,
|
|
134
|
+
"depth": self.path.count("/"),
|
|
135
|
+
"track": self.track,
|
|
136
|
+
"start": self.started_at,
|
|
137
|
+
"dur_ms": self.duration_ms,
|
|
138
|
+
}
|
|
139
|
+
if self.attributes:
|
|
140
|
+
record["args"] = self.attributes
|
|
141
|
+
if self.error:
|
|
142
|
+
record["error"] = self.error
|
|
143
|
+
try:
|
|
144
|
+
self._store.record_span(metrics, record)
|
|
145
|
+
except Exception as e: # a measurement must never break the thing measured
|
|
146
|
+
logger.warning(f"Failed to record span {self.path!r}: {e}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _NullSpan(Span):
|
|
150
|
+
"""What you get without a run: still times, records nothing."""
|
|
151
|
+
|
|
152
|
+
def __init__(self, name: str, **attributes: Any):
|
|
153
|
+
super().__init__(name, None, **attributes)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def start_span(name: str, **attributes: Any) -> Span:
|
|
157
|
+
"""Begin a span that you will ``end()`` yourself, possibly in another scope."""
|
|
158
|
+
from .run import current_run
|
|
159
|
+
|
|
160
|
+
run = current_run()
|
|
161
|
+
store = run.history if run is not None else None
|
|
162
|
+
span = Span(name, store, **attributes) if store is not None else _NullSpan(name)
|
|
163
|
+
return span.begin()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class span:
|
|
167
|
+
"""Time a region, as a context manager, an async context manager or a decorator.
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
with et.span("forward"):
|
|
171
|
+
with et.span("attention"):
|
|
172
|
+
...
|
|
173
|
+
|
|
174
|
+
@et.span("preprocess")
|
|
175
|
+
def preprocess(batch): ...
|
|
176
|
+
|
|
177
|
+
async with et.span("fetch"):
|
|
178
|
+
await loader.next()
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Nested names join with ``/``, so the duration lands on the metric
|
|
182
|
+
``forward/attention/duration_ms`` and window functions work on it directly.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def __init__(self, name: str, **attributes: Any):
|
|
186
|
+
self._name = name
|
|
187
|
+
self._attributes = attributes
|
|
188
|
+
self._span: Span | None = None
|
|
189
|
+
|
|
190
|
+
def __enter__(self) -> Span:
|
|
191
|
+
self._span = start_span(self._name, **self._attributes)
|
|
192
|
+
return self._span
|
|
193
|
+
|
|
194
|
+
def __exit__(self, kind, value, traceback) -> bool:
|
|
195
|
+
if self._span is not None:
|
|
196
|
+
self._span.end(value)
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
async def __aenter__(self) -> Span:
|
|
200
|
+
return self.__enter__()
|
|
201
|
+
|
|
202
|
+
async def __aexit__(self, kind, value, traceback) -> bool:
|
|
203
|
+
return self.__exit__(kind, value, traceback)
|
|
204
|
+
|
|
205
|
+
def __call__(self, fn):
|
|
206
|
+
import asyncio
|
|
207
|
+
|
|
208
|
+
if asyncio.iscoroutinefunction(fn):
|
|
209
|
+
|
|
210
|
+
@functools.wraps(fn)
|
|
211
|
+
async def async_wrapper(*args, **kwargs):
|
|
212
|
+
current = start_span(self._name, **self._attributes)
|
|
213
|
+
error = None
|
|
214
|
+
try:
|
|
215
|
+
return await fn(*args, **kwargs)
|
|
216
|
+
except BaseException as e:
|
|
217
|
+
error = e
|
|
218
|
+
raise
|
|
219
|
+
finally:
|
|
220
|
+
current.end(error)
|
|
221
|
+
|
|
222
|
+
return async_wrapper
|
|
223
|
+
|
|
224
|
+
@functools.wraps(fn)
|
|
225
|
+
def wrapper(*args, **kwargs):
|
|
226
|
+
current = start_span(self._name, **self._attributes)
|
|
227
|
+
error = None
|
|
228
|
+
try:
|
|
229
|
+
return fn(*args, **kwargs)
|
|
230
|
+
except BaseException as e:
|
|
231
|
+
error = e
|
|
232
|
+
raise
|
|
233
|
+
finally:
|
|
234
|
+
current.end(error)
|
|
235
|
+
|
|
236
|
+
return wrapper
|
expr_tracker/trace.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Export recorded spans as a Chrome Trace, readable by Perfetto and chrome://tracing.
|
|
2
|
+
|
|
3
|
+
Writing the format rather than a viewer means the timeline can sit next to a
|
|
4
|
+
``torch.profiler`` trace in the same window, which is where the interesting
|
|
5
|
+
question usually is: what were the GPUs doing while the data loader stalled.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .history.naming import parse_stream, spans_filename
|
|
15
|
+
|
|
16
|
+
MICROSECONDS = 1_000_000.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def span_files(run: str | Path, stream: str | list[str] | None = "*") -> list[Path]:
|
|
20
|
+
"""Span files of a run: every stream by default, or the ones named."""
|
|
21
|
+
directory = Path(run)
|
|
22
|
+
if directory.is_file():
|
|
23
|
+
return [directory]
|
|
24
|
+
if stream == "*":
|
|
25
|
+
# Default producer first: "spans.data.jsonl" sorts before "spans.jsonl"
|
|
26
|
+
return sorted(
|
|
27
|
+
directory.glob("spans*.jsonl"),
|
|
28
|
+
key=lambda p: (parse_stream(p.name) is not None, p.name),
|
|
29
|
+
)
|
|
30
|
+
wanted = [stream] if isinstance(stream, (str, type(None))) else list(stream)
|
|
31
|
+
found = []
|
|
32
|
+
for name in wanted:
|
|
33
|
+
path = directory / spans_filename(name, rank_aware=False)
|
|
34
|
+
if not path.is_file():
|
|
35
|
+
raise FileNotFoundError(f"No {path.name} under {directory}")
|
|
36
|
+
found.append(path)
|
|
37
|
+
return found
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_spans(path: Path) -> list[dict]:
|
|
41
|
+
"""Decode one span file, skipping anything malformed."""
|
|
42
|
+
if not path.is_file():
|
|
43
|
+
return []
|
|
44
|
+
spans = []
|
|
45
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
46
|
+
if not line.strip():
|
|
47
|
+
continue
|
|
48
|
+
try:
|
|
49
|
+
record = json.loads(line)
|
|
50
|
+
except ValueError:
|
|
51
|
+
continue
|
|
52
|
+
if isinstance(record, dict) and "start" in record and "dur_ms" in record:
|
|
53
|
+
spans.append(record)
|
|
54
|
+
return spans
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _pack(spans: list[dict], indices: list[int], offset: int) -> dict[int, int]:
|
|
58
|
+
"""Lay one track's spans onto lanes, each lane a valid stack."""
|
|
59
|
+
order = sorted(
|
|
60
|
+
indices, key=lambda i: (spans[i]["start"], -float(spans[i]["dur_ms"]))
|
|
61
|
+
)
|
|
62
|
+
lanes: list[list[float]] = [] # per lane, the end times currently open
|
|
63
|
+
placement: dict[int, int] = {}
|
|
64
|
+
for index in order:
|
|
65
|
+
start = float(spans[index]["start"])
|
|
66
|
+
end = start + float(spans[index]["dur_ms"]) / 1000.0
|
|
67
|
+
for lane, stack in enumerate(lanes):
|
|
68
|
+
while stack and stack[-1] <= start:
|
|
69
|
+
stack.pop()
|
|
70
|
+
if not stack or end <= stack[-1]: # free, or nests inside the top
|
|
71
|
+
stack.append(end)
|
|
72
|
+
placement[index] = offset + lane
|
|
73
|
+
break
|
|
74
|
+
else:
|
|
75
|
+
lanes.append([end])
|
|
76
|
+
placement[index] = offset + len(lanes) - 1
|
|
77
|
+
return placement
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def assign_lanes(spans: list[dict]) -> dict[int, int]:
|
|
81
|
+
"""Place spans on lanes so each lane is a valid stack, keyed by list index.
|
|
82
|
+
|
|
83
|
+
Threads come first: work on different threads is unrelated, and letting one
|
|
84
|
+
contain the other would draw a nesting that never happened. Within a thread,
|
|
85
|
+
anything still overlapping -- concurrent asyncio tasks -- gets its own lane,
|
|
86
|
+
because a Chrome Trace track must be properly nested to render at all.
|
|
87
|
+
"""
|
|
88
|
+
by_track: dict[Any, list[int]] = {}
|
|
89
|
+
for index, record in enumerate(spans):
|
|
90
|
+
by_track.setdefault(record.get("track", 0), []).append(index)
|
|
91
|
+
placement: dict[int, int] = {}
|
|
92
|
+
offset = 0
|
|
93
|
+
for track in sorted(by_track, key=str):
|
|
94
|
+
lanes = _pack(spans, by_track[track], offset)
|
|
95
|
+
placement.update(lanes)
|
|
96
|
+
offset = max(lanes.values(), default=offset - 1) + 1
|
|
97
|
+
return placement
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_trace(
|
|
101
|
+
run: str | Path,
|
|
102
|
+
stream: str | list[str] | None = "*",
|
|
103
|
+
*,
|
|
104
|
+
step_range: tuple[int | None, int | None] | None = None,
|
|
105
|
+
) -> dict[str, Any]:
|
|
106
|
+
"""Chrome Trace Event Format for a run's spans, one process per stream."""
|
|
107
|
+
events: list[dict] = []
|
|
108
|
+
origin: float | None = None
|
|
109
|
+
for pid, path in enumerate(span_files(run, stream)):
|
|
110
|
+
spans = read_spans(path)
|
|
111
|
+
if step_range is not None:
|
|
112
|
+
low, high = step_range
|
|
113
|
+
spans = [
|
|
114
|
+
s
|
|
115
|
+
for s in spans
|
|
116
|
+
if (low is None or s.get("_step", 0) >= low)
|
|
117
|
+
and (high is None or s.get("_step", 0) < high)
|
|
118
|
+
]
|
|
119
|
+
name = parse_stream(path.name) or "default"
|
|
120
|
+
events.append(
|
|
121
|
+
{
|
|
122
|
+
"name": "process_name",
|
|
123
|
+
"ph": "M",
|
|
124
|
+
"pid": pid,
|
|
125
|
+
"tid": 0,
|
|
126
|
+
"args": {"name": name},
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
if not spans:
|
|
130
|
+
continue
|
|
131
|
+
lanes = assign_lanes(spans)
|
|
132
|
+
tracks = sorted({s.get("track", 0) for s in spans}, key=str)
|
|
133
|
+
# Label each lane with the thread it came from
|
|
134
|
+
lane_track: dict[int, Any] = {}
|
|
135
|
+
for index, record in enumerate(spans):
|
|
136
|
+
lane_track.setdefault(lanes[index], record.get("track", 0))
|
|
137
|
+
for tid, track in sorted(lane_track.items()):
|
|
138
|
+
label = f"thread {track}" if len(tracks) > 1 else name
|
|
139
|
+
events.append(
|
|
140
|
+
{
|
|
141
|
+
"name": "thread_name",
|
|
142
|
+
"ph": "M",
|
|
143
|
+
"pid": pid,
|
|
144
|
+
"tid": tid,
|
|
145
|
+
"args": {"name": label},
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
for index, record in enumerate(spans):
|
|
149
|
+
start = float(record["start"])
|
|
150
|
+
origin = start if origin is None else min(origin, start)
|
|
151
|
+
args = dict(record.get("args") or {})
|
|
152
|
+
args["step"] = record.get("_step")
|
|
153
|
+
if record.get("error"):
|
|
154
|
+
args["error"] = record["error"]
|
|
155
|
+
if len(tracks) > 1:
|
|
156
|
+
args["track"] = record.get("track", 0)
|
|
157
|
+
events.append(
|
|
158
|
+
{
|
|
159
|
+
"name": record.get("name", "span"),
|
|
160
|
+
"cat": name,
|
|
161
|
+
"ph": "X",
|
|
162
|
+
"pid": pid,
|
|
163
|
+
"tid": lanes[index],
|
|
164
|
+
"ts": start * MICROSECONDS,
|
|
165
|
+
"dur": float(record["dur_ms"]) * 1000.0,
|
|
166
|
+
"args": args,
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
# Relative timestamps keep the numbers readable and the streams aligned
|
|
170
|
+
if origin is not None:
|
|
171
|
+
for event in events:
|
|
172
|
+
if "ts" in event:
|
|
173
|
+
event["ts"] -= origin * MICROSECONDS
|
|
174
|
+
return {"traceEvents": events, "displayTimeUnit": "ms"}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def write_trace(
|
|
178
|
+
run: str | Path,
|
|
179
|
+
output: str | Path,
|
|
180
|
+
stream: str | list[str] | None = "*",
|
|
181
|
+
*,
|
|
182
|
+
step_range: tuple[int | None, int | None] | None = None,
|
|
183
|
+
) -> int:
|
|
184
|
+
"""Write the trace and return how many spans it holds."""
|
|
185
|
+
trace = build_trace(run, stream, step_range=step_range)
|
|
186
|
+
path = Path(output)
|
|
187
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
path.write_text(json.dumps(trace), encoding="utf-8")
|
|
189
|
+
return sum(1 for event in trace["traceEvents"] if event["ph"] == "X")
|
expr_tracker/tracker.py
CHANGED
|
@@ -10,6 +10,7 @@ from typing import Literal
|
|
|
10
10
|
from .artifacts import Artifact
|
|
11
11
|
from .history import read_history
|
|
12
12
|
from .run import Run, current_run, require_run, set_run
|
|
13
|
+
from .run import current_run as current
|
|
13
14
|
|
|
14
15
|
Tracker = Run
|
|
15
16
|
|
|
@@ -70,9 +71,23 @@ def log(data: dict, step: int | None = None, commit: bool | None = None):
|
|
|
70
71
|
require_run().log(data, step=step, commit=commit)
|
|
71
72
|
|
|
72
73
|
|
|
74
|
+
class _Current:
|
|
75
|
+
"""Sentinel: ``stream`` was not given, so use the running stream.
|
|
76
|
+
|
|
77
|
+
``stream=None`` has to keep meaning the default, unnamed producer.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
81
|
+
return "<current stream>"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
CURRENT_STREAM = _Current()
|
|
85
|
+
|
|
86
|
+
|
|
73
87
|
def history(
|
|
74
88
|
n: int | None = 50,
|
|
75
89
|
*,
|
|
90
|
+
stream: str | _Current | None = CURRENT_STREAM,
|
|
76
91
|
output_type: str = "dict",
|
|
77
92
|
metrics: Sequence[str] | None = None,
|
|
78
93
|
step_range: tuple[int | None, int | None] | None = None,
|
|
@@ -86,25 +101,32 @@ def history(
|
|
|
86
101
|
|
|
87
102
|
``output_type="pandas"`` returns a DataFrame (pandas is an optional extra).
|
|
88
103
|
Passing ``run`` reads that run directory or file offline, without ``init()``.
|
|
104
|
+
``stream`` selects an independent producer, ``None`` being the default one;
|
|
105
|
+
another process's stream is read from its file, so it reflects what that
|
|
106
|
+
process has flushed. Omit it to read whichever stream this process writes.
|
|
89
107
|
"""
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
108
|
+
active = getattr(current(), "stream", None)
|
|
109
|
+
if run is None and (stream is CURRENT_STREAM or stream == active):
|
|
110
|
+
return require_run().history.get(
|
|
93
111
|
n,
|
|
94
112
|
output_type=output_type,
|
|
95
113
|
metrics=metrics,
|
|
96
114
|
step_range=step_range,
|
|
97
115
|
include_meta=include_meta,
|
|
116
|
+
include_open=include_open,
|
|
98
117
|
fill_missing=fill_missing,
|
|
99
118
|
dropna=dropna,
|
|
100
119
|
)
|
|
101
|
-
|
|
120
|
+
if run is None:
|
|
121
|
+
run = require_run().history.log_dir
|
|
122
|
+
return read_history(
|
|
123
|
+
run,
|
|
102
124
|
n,
|
|
125
|
+
stream=None if stream is CURRENT_STREAM else stream,
|
|
103
126
|
output_type=output_type,
|
|
104
127
|
metrics=metrics,
|
|
105
128
|
step_range=step_range,
|
|
106
129
|
include_meta=include_meta,
|
|
107
|
-
include_open=include_open,
|
|
108
130
|
fill_missing=fill_missing,
|
|
109
131
|
dropna=dropna,
|
|
110
132
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: expr_tracker
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Local-first experiment tracking with queryable history and expression-based alerts on your training metrics
|
|
5
5
|
Project-URL: Homepage, https://hspk.github.io/expr_tracker/
|
|
6
6
|
Project-URL: Documentation, https://hspk.github.io/expr_tracker/
|
|
@@ -106,6 +106,8 @@ install command; it never crashes a run.
|
|
|
106
106
|
| [Alerts](https://hspk.github.io/expr_tracker/guide/alerts/) | An expression DSL with rolling windows, three-valued logic (no false alarms during warm-up), a rule state machine, and a watchdog that catches a hung run. |
|
|
107
107
|
| [Channels](https://hspk.github.io/expr_tracker/guide/alerts/#channels) | Lark, Slack, DingTalk, WeCom, generic webhook, email — with rate limiting, dedup, retries and per-channel routing. |
|
|
108
108
|
| [Artifacts](https://hspk.github.io/expr_tracker/guide/artifacts/) | Versioned file sets, deduplicated by content, shared across a project's runs, with lineage. |
|
|
109
|
+
| [Spans](https://hspk.github.io/expr_tracker/guide/spans/) | Time the parts of a step, and their parts. Each duration becomes a metric, so alerts and queries work on it unchanged; `et trace` exports the timeline for Perfetto. |
|
|
110
|
+
| [Streams](https://hspk.github.io/expr_tracker/guide/streams/) | Independent producers — a data worker and a training loop — each with their own step cursor and file inside one run. |
|
|
109
111
|
| [Distributed](https://hspk.github.io/expr_tracker/guide/distributed/) | Per-rank shards so concurrent appends cannot corrupt step order; only rank 0 alerts by default. |
|
|
110
112
|
| [CLI](https://hspk.github.io/expr_tracker/guide/cli/) | `et history`, `et rules explain`, `et rules test`, `et alert`. |
|
|
111
113
|
|
|
@@ -148,6 +150,9 @@ uv run ruff format src tests
|
|
|
148
150
|
| `test_hot_paths.py` | contracts and defaults of `et.log` / `et.history` / summary / alerts |
|
|
149
151
|
| `test_value_encoding.py` | numpy, pydantic, datetime, Path, Enum round trips; output types; query bounds |
|
|
150
152
|
| `test_expr_properties.py` | DSL properties: render round-trip stability, precedence, the whole `M` builder |
|
|
153
|
+
| `test_trace.py` | Chrome Trace export: lane layout, stream and step selection, the CLI |
|
|
154
|
+
| `test_spans.py` | nesting, aggregation, decorator and async forms, errors, thread and task isolation |
|
|
155
|
+
| `test_streams.py` | stream naming and validation, isolation, resolution order, backend grouping, two-process runs |
|
|
151
156
|
| `test_distributed.py` | rank shards, `alert_on_rank`, real multi-process runs |
|
|
152
157
|
| `test_wandb.py` | real wandb in offline mode: parameter mapping, step alignment, artifacts |
|
|
153
158
|
| `test_trackio.py` | trackio contract, resume mapping, real end-to-end |
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
expr_tracker/__init__.py,sha256=
|
|
1
|
+
expr_tracker/__init__.py,sha256=wWvM6nZSIlP_pWYcmQGEbMMD9GCBA0hpatui5TNwPHI,1418
|
|
2
2
|
expr_tracker/_compat.py,sha256=qf8oYIK_lkoLzcPp6wPCYLJtVatCl7ROYRNPwc5F1Pg,981
|
|
3
3
|
expr_tracker/artifacts.py,sha256=yRVXmuqdZUxiZa0c_CwZi4rBxaItwhu9bZ1x21yP1S0,12560
|
|
4
|
-
expr_tracker/cli.py,sha256=
|
|
4
|
+
expr_tracker/cli.py,sha256=dtp0YsNtGg7T7M-elA511YX8NU_-OJRAwNbs8I1353o,7255
|
|
5
5
|
expr_tracker/encoders.py,sha256=X5ySQasSrYPsdYCdk7nhpJFGFB9f_8HWQuGOQjTvh9E,11991
|
|
6
6
|
expr_tracker/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
-
expr_tracker/run.py,sha256=
|
|
7
|
+
expr_tracker/run.py,sha256=nuXhNmepG688X86cI0FMM_hqjyygD1Oi8Ck8-lyHsPA,16664
|
|
8
|
+
expr_tracker/spans.py,sha256=su1M0yxgi51911a6rBPaWhDoVBfIW1vla_okYCW9Z28,7365
|
|
8
9
|
expr_tracker/summary.py,sha256=lpHSh3pci2Ym6wJhVZwNUJbUp2A64EN_rC7Ym2d7PPI,3150
|
|
9
|
-
expr_tracker/
|
|
10
|
+
expr_tracker/trace.py,sha256=lag-T-5F1LOz4MKsxeMfTW1FJN0KxxznJ48sYT7vX28,6966
|
|
11
|
+
expr_tracker/tracker.py,sha256=hpZB8bFA2GqCbvHEDZkBS9cCzi2JgXeLUNTzJm-wr8c,5921
|
|
10
12
|
expr_tracker/types.py,sha256=69gFtUaKGl4asLK7nJci9XyTyV0yTIPorB-bcfGrCBA,383
|
|
11
13
|
expr_tracker/alerts/__init__.py,sha256=dl96OYZh75kV3NLBSA35zNvTFcL6XFMuPoYvRoz7hlQ,8192
|
|
12
14
|
expr_tracker/alerts/dispatch.py,sha256=k2pDZVJy6ajlQ7kUdvaBBZ_AmdtZ8opaQWjADn_i8uI,11652
|
|
@@ -21,15 +23,16 @@ expr_tracker/alerts/expr/lexer.py,sha256=NrvDi-f_8F9V1_vQT-vAGENNA8nauD9IbJH4r21
|
|
|
21
23
|
expr_tracker/alerts/expr/nodes.py,sha256=ZsXX4VJDolVQsVoEtIhkDj6QspMz7lTfRYaEZ8F6grg,9896
|
|
22
24
|
expr_tracker/alerts/expr/parser.py,sha256=heGXAVjCZ1AM0u5-Usld4G_P38N0nUaXMlh8C7CHqjQ,6315
|
|
23
25
|
expr_tracker/alerts/expr/rule.py,sha256=nO8vzhWyGDxSmSXqJrXOU_Yoy7Tzot3_c2W2bZ4s2Qw,3653
|
|
24
|
-
expr_tracker/history/__init__.py,sha256=
|
|
26
|
+
expr_tracker/history/__init__.py,sha256=oSAamhXglLh1oqIHI0-DqQHlR97yt1gLBWSTlyFbCo0,716
|
|
25
27
|
expr_tracker/history/codec.py,sha256=iD0TmnmahpRmSRRXt1-lmTPPOzL4TdA4uRQlhMdg49o,3121
|
|
26
28
|
expr_tracker/history/frame.py,sha256=PFDGqR0oHY94KosSXA7Fu-bphEhZ2yM8cXW9rlZ2IC0,2996
|
|
27
|
-
expr_tracker/history/
|
|
29
|
+
expr_tracker/history/naming.py,sha256=CT2xDpo0GHYnLyjgRlsPjIX3GAIDEdn4U9I1-cNahHw,2507
|
|
30
|
+
expr_tracker/history/reader.py,sha256=5PY5nbNfX-Cl34jcyYTq2qm8nNgNDTqv7U5Xnb9Rgxw,14155
|
|
28
31
|
expr_tracker/history/series.py,sha256=cFdUO1FRs5KW0jYsNPzda3tcu645NH1wwfXJitQfXFM,4042
|
|
29
|
-
expr_tracker/history/store.py,sha256=
|
|
32
|
+
expr_tracker/history/store.py,sha256=ZazPIcQ0Aj23DzIBN7B-xiIAk5ej6wfwsUdBYNywHNw,36047
|
|
30
33
|
expr_tracker/history/writer.py,sha256=q5kNANcZCq5nykvOK5jZ2xc6Z1B4oZxEn3uSXzDCtlY,16096
|
|
31
|
-
expr_tracker-0.2.
|
|
32
|
-
expr_tracker-0.2.
|
|
33
|
-
expr_tracker-0.2.
|
|
34
|
-
expr_tracker-0.2.
|
|
35
|
-
expr_tracker-0.2.
|
|
34
|
+
expr_tracker-0.2.3.dist-info/METADATA,sha256=HXNy4hcFe74JnO8dYmnPfoCqt2IJpjWJT34E6eK6yYE,9120
|
|
35
|
+
expr_tracker-0.2.3.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
36
|
+
expr_tracker-0.2.3.dist-info/entry_points.txt,sha256=GfZCAG2JFsd5CxTjRsGJAhYInpBCuLjrU3csmUVBDSs,45
|
|
37
|
+
expr_tracker-0.2.3.dist-info/licenses/LICENSE,sha256=ORsYTnF-2SLUaM8FgXlJ-uXuN4ceGACAAU3JZiS8pvs,1069
|
|
38
|
+
expr_tracker-0.2.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|