expr_tracker 0.2.2__py3-none-any.whl → 0.2.4__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 CHANGED
@@ -1,7 +1,7 @@
1
1
  from importlib.metadata import PackageNotFoundError
2
2
  from importlib.metadata import version as _version
3
3
 
4
- from . import tracker
4
+ from . import plugins, tracker
5
5
  from .alerts import (
6
6
  AlertConfig,
7
7
  AlertLevel,
@@ -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",
@@ -59,8 +61,11 @@ __all__ = [
59
61
  "list_alert_rules",
60
62
  "log",
61
63
  "log_artifact",
64
+ "plugins",
62
65
  "register_backend",
63
66
  "remove_alert_rule",
67
+ "span",
68
+ "start_span",
64
69
  "summary",
65
70
  "use_artifact",
66
71
  ]
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."""
@@ -1,8 +1,15 @@
1
1
  from .codec import RecordCodec
2
2
  from .frame import project, to_output
3
- from .reader import JsonlReader, merge_steps, read_history, resolve_run_path
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, current_rank, resolve_commit
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
@@ -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
- candidates = sorted(path.glob("metrics*.jsonl"))
354
- if not candidates:
355
- raise FileNotFoundError(f"No metrics*.jsonl found under {path}")
356
- return candidates[0]
357
- if not path.exists():
358
- raise FileNotFoundError(f"Run path does not exist: {path}")
359
- return path
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 ""))
@@ -13,18 +13,24 @@ 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
20
19
  from collections.abc import Callable, Sequence
21
20
  from dataclasses import dataclass, fields
22
21
  from pathlib import Path
22
+ from typing import Any
23
23
 
24
24
  from loguru import logger
25
25
 
26
26
  from .codec import RESERVED_KEYS, RecordCodec, encode_line
27
27
  from .frame import project, to_output
28
+ from .naming import (
29
+ metrics_filename,
30
+ sidecar_filename,
31
+ spans_filename,
32
+ validate_stream,
33
+ )
28
34
  from .reader import JsonlReader, merge_steps
29
35
  from .series import DEFAULT_WINDOW, MetricSeries
30
36
  from .writer import (
@@ -65,6 +71,13 @@ class HistoryOptions:
65
71
  max_open_seconds: float | None = DEFAULT_MAX_OPEN_SECONDS
66
72
  step_policy: str = "monotonic"
67
73
  rank_aware: bool = True
74
+ # an independent producer writing its own file with its own step cursor
75
+ stream: str | None = None
76
+ # write the full span tree to spans.jsonl beside the metrics
77
+ spans: bool = True
78
+ # run-wide defaults for et.span(); a span's own arguments win
79
+ span_print_fn: Callable[[str], None] | None = None
80
+ span_plugins: Sequence[Any] = ()
68
81
  # alerts and output
69
82
  alert_window: int = DEFAULT_WINDOW
70
83
  print_to_screen: bool = False
@@ -76,6 +89,8 @@ class HistoryOptions:
76
89
  f"Unknown step_policy {self.step_policy!r}; "
77
90
  f"expected one of {', '.join(STEP_POLICIES)}."
78
91
  )
92
+ if self.stream is not None:
93
+ object.__setattr__(self, "stream", validate_stream(self.stream))
79
94
  clamped = {
80
95
  "cache_bytes": max(0, int(self.cache_bytes)),
81
96
  "cache_rows": max(1, int(self.cache_rows)),
@@ -128,6 +143,9 @@ class HistoryStore:
128
143
  self.log_dir: Path | None = None
129
144
  self.log_fp: Path | None = None
130
145
  self.config_fp: Path | None = None
146
+ self.spans_fp: Path | None = None
147
+ self.span_writer: JsonlWriter | None = None
148
+ self.stream: str | None = None
131
149
  self.project: str = ""
132
150
  self.name: str = ""
133
151
  self._reset(HistoryOptions(), on_commit=None)
@@ -177,12 +195,29 @@ class HistoryStore:
177
195
  self.project, self.name = project, name
178
196
  self.log_dir = Path(dir or "./tracker/jsonl") / project / name
179
197
  self.log_dir.mkdir(parents=True, exist_ok=True)
180
- self.config_fp = self.log_dir / "config.json"
181
- self.log_fp = self.log_dir / _metrics_filename(opts.rank_aware)
198
+ self.stream = opts.stream
199
+ self.config_fp = self.log_dir / sidecar_filename("config", opts.stream, "json")
200
+ self.spans_fp = (
201
+ self.log_dir / spans_filename(opts.stream, opts.rank_aware)
202
+ if opts.spans
203
+ else None
204
+ )
205
+ self.log_fp = self.log_dir / metrics_filename(opts.stream, opts.rank_aware)
182
206
 
183
207
  self._close_previous()
184
208
  self._reset(opts, on_commit)
185
209
  self.writer = self._open_writer(opts)
210
+ self.span_writer = (
211
+ JsonlWriter(
212
+ self.spans_fp,
213
+ buffer_size=opts.buffer_size,
214
+ buffer_interval=opts.buffer_interval,
215
+ max_buffer_seconds=opts.max_buffer_seconds,
216
+ max_pending_records=opts.max_pending_records,
217
+ )
218
+ if self.spans_fp is not None
219
+ else None
220
+ )
186
221
  self._resume(opts.alert_window)
187
222
  self._write_config(config)
188
223
  self._register_atexit()
@@ -191,8 +226,9 @@ class HistoryStore:
191
226
  def _close_previous(self):
192
227
  """Re-initialising must not strand the previous writer's buffer or timer."""
193
228
  self._cancel_open_timer()
194
- if self.writer is not None:
195
- self.writer.close()
229
+ for writer in (self.writer, self.span_writer):
230
+ if writer is not None:
231
+ writer.close()
196
232
 
197
233
  def _open_writer(self, options: HistoryOptions) -> JsonlWriter:
198
234
  assert self.log_fp is not None
@@ -276,7 +312,24 @@ class HistoryStore:
276
312
  ``is None``, since step 0 is falsy.
277
313
  """
278
314
  self._require_init()
279
- encoded = self._codec.encode(metrics)
315
+ return self.ingest(self._codec.encode(metrics), step=step, commit=commit)
316
+
317
+ def ingest(
318
+ self,
319
+ encoded: dict,
320
+ *,
321
+ step: int | None = None,
322
+ commit: bool | None = None,
323
+ accumulate: bool = False,
324
+ ) -> int | None:
325
+ """The single path metrics take into the open row.
326
+
327
+ ``log()`` and span durations both come through here, so the step policy,
328
+ the merge and the commit rules only exist once. ``accumulate`` adds to any
329
+ existing value instead of replacing it, which is what repeated spans in
330
+ one step need.
331
+ """
332
+ self._require_init()
280
333
  now = time.time()
281
334
  pending: list[dict] = []
282
335
  with self._lock:
@@ -287,7 +340,7 @@ class HistoryStore:
287
340
  return None
288
341
  pending.append(self._switch_open_row(step, now))
289
342
  resolved = self._open_step
290
- self._update_open_row(encoded)
343
+ self._update_open_row(encoded, accumulate)
291
344
  if resolve_commit(step, commit):
292
345
  pending.append(self._close_open_row())
293
346
  for record in filter(None, pending):
@@ -295,6 +348,28 @@ class HistoryStore:
295
348
  self._schedule_open_timer()
296
349
  return resolved
297
350
 
351
+ def record_span(self, metrics: dict, record: dict | None = None) -> int | None:
352
+ """A finished span: durations join the open row, the tree goes to disk.
353
+
354
+ Durations never commit a step of their own -- they ride along with
355
+ whatever ``log()`` commits, so a span costs no extra row. The metrics are
356
+ built from floats and ints here, so they skip the encoder: spans are
357
+ recorded per sub-step and cannot afford it.
358
+ """
359
+ step = self.ingest(metrics, commit=False, accumulate=True)
360
+ if record is None or self.span_writer is None or step is None:
361
+ return step
362
+ try:
363
+ line = encode_line({"_step": step, **record})
364
+ except Exception as e:
365
+ logger.warning(f"Dropping span that cannot be serialized: {e}")
366
+ return step
367
+ # enqueue, not append: the batch goes out when the step commits, so a
368
+ # span does not pay for a flush decision of its own
369
+ if self.span_writer.enqueue(step, self.span_writer.lines, line):
370
+ self.span_writer.flush()
371
+ return step
372
+
298
373
  def _switch_open_row(self, step: int | None, now: float) -> dict | None:
299
374
  """Point the open row at ``step``, returning the row it displaced, if any.
300
375
 
@@ -331,12 +406,18 @@ class HistoryStore:
331
406
  if timer is not None:
332
407
  timer.cancel()
333
408
 
334
- def _update_open_row(self, encoded: dict):
409
+ def _update_open_row(self, encoded: dict, accumulate: bool = False):
335
410
  for key in RESERVED_KEYS & set(encoded):
336
411
  self._codec.warn_once(
337
412
  key, f"Metric name {key!r} is reserved and will be ignored."
338
413
  )
339
414
  encoded.pop(key)
415
+ if accumulate:
416
+ # Repeated spans in one step sum rather than overwrite
417
+ for key, value in encoded.items():
418
+ current = self._open_row.get(key)
419
+ self._open_row[key] = value + current if _addable(current) else value
420
+ return
340
421
  for key in encoded:
341
422
  if key in self._open_row:
342
423
  self._codec.warn_once(
@@ -480,8 +561,9 @@ class HistoryStore:
480
561
  record = self._close_open_row()
481
562
  if record is not None:
482
563
  self._emit(record)
483
- if self.writer is not None:
484
- self.writer.flush()
564
+ for writer in (self.writer, self.span_writer):
565
+ if writer is not None:
566
+ writer.flush()
485
567
 
486
568
  def finish(self):
487
569
  """Close the store. Never raises: a bad disk must not mask the real error."""
@@ -492,11 +574,12 @@ class HistoryStore:
492
574
  self.flush(commit_open=True)
493
575
  except Exception as e:
494
576
  logger.warning(f"Failed to flush history on finish: {e}")
495
- if self.writer is not None:
496
- try:
497
- self.writer.close()
498
- except Exception as e:
499
- logger.warning(f"Failed to close {self.log_fp}: {e}")
577
+ for writer in (self.writer, self.span_writer):
578
+ if writer is not None:
579
+ try:
580
+ writer.close()
581
+ except Exception as e:
582
+ logger.warning(f"Failed to close {writer.path}: {e}")
500
583
  if self._atexit_registered:
501
584
  atexit.unregister(self._atexit)
502
585
  self._atexit_registered = False
@@ -758,19 +841,8 @@ class HistoryStore:
758
841
  # ---------------------------------------------------------------------- helpers
759
842
 
760
843
 
761
- def current_rank() -> int:
762
- """Distributed rank from the usual environment variables, 0 when unset."""
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"
844
+ def _addable(value) -> bool:
845
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
774
846
 
775
847
 
776
848
  def _parse_cached(entries: Sequence[tuple[int, int, bytes]]) -> list[dict]: