expr-tracker 0.2.7__tar.gz → 0.3.0__tar.gz
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-0.2.7 → expr_tracker-0.3.0}/PKG-INFO +1 -1
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/design.md +7 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/getting-started.md +26 -7
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/artifacts.md +11 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/spans.md +29 -13
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/reference/api.md +12 -3
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/examples/profile_step.py +7 -7
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/history/naming.py +36 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/history/store.py +7 -1
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/run.py +4 -3
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/spans.py +22 -6
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/tracker.py +6 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_examples.py +8 -8
- expr_tracker-0.3.0/tests/test_run_dir.py +214 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_span_plugins.py +16 -15
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_spans.py +113 -33
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/.github/workflows/docs.yaml +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/.github/workflows/release.yaml +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/.gitignore +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/LICENSE +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/README.md +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/architecture.md +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/examples.md +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/alerts.md +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/backends.md +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/cli.md +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/distributed.md +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/history.md +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/logging.md +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/streams.md +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/index.md +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/reference/configuration.md +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/reference/expressions.md +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/examples/README.md +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/examples/alert_rules.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/examples/checkpoints.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/examples/early_stopping.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/examples/multiprocess_pipeline.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/examples/quickstart.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/mkdocs.yml +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/pyproject.toml +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/__init__.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/_compat.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/__init__.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/backends/__init__.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/backends/base.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/backends/cards.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/dispatch.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/engine.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/expr/__init__.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/expr/eval.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/expr/functions.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/expr/lexer.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/expr/nodes.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/expr/parser.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/expr/rule.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/models.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/artifacts.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/cli.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/encoders.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/history/__init__.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/history/codec.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/history/frame.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/history/reader.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/history/series.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/history/writer.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/plugins.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/py.typed +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/summary.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/trace.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/types.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/conftest.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_alert_backends.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_alert_delivery.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_alert_dispatch.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_alert_engine.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_alert_models.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_alert_routing.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_artifacts.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_benchmark.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_cache.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_cli.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_correctness.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_distributed.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_e2e.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_expr_builder.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_expr_eval.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_expr_functions.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_expr_parser.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_expr_properties.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_failure_modes.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_features.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_frame_codec_summary.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_history.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_hot_paths.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_integration.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_lark.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_perf.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_public_surfaces.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_review_regressions.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_rule_lifecycle.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_run_backends.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_scenarios.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_step_commit.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_streams.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_stress.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_trace.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_trackio.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_value_encoding.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_wandb.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_writer_buffer.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_writer_durability.py +0 -0
- {expr_tracker-0.2.7 → expr_tracker-0.3.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: expr_tracker
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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,13 @@ Two differences from a log call:
|
|
|
106
106
|
- the metrics are built here from floats and ints, so they skip `RecordCodec`.
|
|
107
107
|
That encoder costs 5 µs, which a per-sub-step call cannot afford.
|
|
108
108
|
|
|
109
|
+
Metrics are named `<measurement>/<span path>` — `time_ms/step/forward`, not
|
|
110
|
+
`step/forward/time_ms`. A tracker UI groups by the segment before the first
|
|
111
|
+
slash, so putting the path first would file every timing under the thing it
|
|
112
|
+
timed, mixing a span called `train` in with `train/loss` and scattering a deep
|
|
113
|
+
tree across the metric namespace. Measurement-first keeps all timings in one
|
|
114
|
+
group and leaves the caller's names alone.
|
|
115
|
+
|
|
109
116
|
A span never commits a step: durations ride along with whatever `log()` commits,
|
|
110
117
|
so timing a region adds no row. The tree, with timestamps and attributes, goes to
|
|
111
118
|
`spans[.stream][.rankN].jsonl` through a second `JsonlWriter`, enqueued rather
|
|
@@ -48,15 +48,34 @@ et.finish()
|
|
|
48
48
|
This writes:
|
|
49
49
|
|
|
50
50
|
```
|
|
51
|
-
./tracker/jsonl/
|
|
52
|
-
|
|
53
|
-
├──
|
|
54
|
-
├──
|
|
55
|
-
├──
|
|
56
|
-
|
|
51
|
+
./tracker/jsonl/ # the root, from dir=
|
|
52
|
+
└── mnist/ # the project
|
|
53
|
+
├── baseline/ # this run
|
|
54
|
+
│ ├── metrics.jsonl # one JSON object per step
|
|
55
|
+
│ ├── metrics.meta.json # index sidecar, for fast resume and seeks
|
|
56
|
+
│ ├── config.json
|
|
57
|
+
│ ├── summary.json
|
|
58
|
+
│ └── artifacts.jsonl # lineage: what this run produced and consumed
|
|
59
|
+
└── artifacts/ # shared by every run of the project
|
|
57
60
|
```
|
|
58
61
|
|
|
59
|
-
|
|
62
|
+
`dir` is a **root**, so a run lands in `<dir>/<project>/<name>`. That is what
|
|
63
|
+
lets a project's runs share and deduplicate artifacts, and what lets a resume
|
|
64
|
+
find its files from the project and name alone. The path is logged at `init()`,
|
|
65
|
+
and `et.get_run().dir` returns it.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
et.init(project="mnist", name="baseline", dir="/data/runs")
|
|
69
|
+
# -> /data/runs/mnist/baseline
|
|
70
|
+
|
|
71
|
+
et.init(project="mnist", name="baseline", run_dir="/data/runs/exp-42")
|
|
72
|
+
# -> /data/runs/exp-42, exactly
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use `run_dir` when something else already chose the path — a scheduler's output
|
|
76
|
+
directory, say. Artifacts then live in `<run_dir>/artifacts` and are no longer
|
|
77
|
+
shared with the project's other runs, which is the price of the flat layout.
|
|
78
|
+
Passing both `dir` and `run_dir` is an error.
|
|
60
79
|
|
|
61
80
|
## Reading it back
|
|
62
81
|
|
|
@@ -33,6 +33,17 @@ assert b.version == 0 # same bytes, same version
|
|
|
33
33
|
et.use_artifact("model:best").version == 0
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
## Where they live
|
|
37
|
+
|
|
38
|
+
The store sits at `<dir>/<project>/artifacts`, beside the project's runs rather
|
|
39
|
+
than inside any one of them. That is what makes sharing and deduplication
|
|
40
|
+
possible: a later run asking for `model:best` finds what an earlier one logged.
|
|
41
|
+
|
|
42
|
+
Naming a run's directory with `run_dir` moves the store to
|
|
43
|
+
`<run_dir>/artifacts`. The run becomes self-contained, and gives up sharing with
|
|
44
|
+
the project's other runs — nothing dedupes against them, and `use_artifact`
|
|
45
|
+
cannot see them.
|
|
46
|
+
|
|
36
47
|
## Storage modes
|
|
37
48
|
|
|
38
49
|
```python
|
|
@@ -20,15 +20,29 @@ The step's row then carries:
|
|
|
20
20
|
|
|
21
21
|
```jsonc
|
|
22
22
|
{"_step": 42, "_time": ...,
|
|
23
|
-
"forward
|
|
24
|
-
"forward/attention
|
|
25
|
-
"forward/mlp
|
|
26
|
-
"backward
|
|
23
|
+
"time_ms/forward": 31.2, "count/forward": 1,
|
|
24
|
+
"time_ms/forward/attention": 18.4, "count/forward/attention": 1,
|
|
25
|
+
"time_ms/forward/mlp": 9.1, "count/forward/mlp": 1,
|
|
26
|
+
"time_ms/backward": 44.7, "count/backward": 1,
|
|
27
27
|
"loss": 0.31}
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
## How a span metric is named
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
<what was measured>/<the span's path>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Nested span names join with `/`, so `forward/norm` and `backward/norm` stay
|
|
37
|
+
distinct, and the alert language reads them directly.
|
|
38
|
+
|
|
39
|
+
The measurement comes **first**, not last, because a tracker UI groups metrics
|
|
40
|
+
by the segment before the first slash. Naming them `forward/time_ms` would file
|
|
41
|
+
every timing under whatever it timed — a span called `train` would put its
|
|
42
|
+
duration in the same group as `train/loss`, and a deep tree would scatter
|
|
43
|
+
timings across the whole metric namespace. This way every timing shares one
|
|
44
|
+
`time_ms` group, every count a `count` group, and your own metric names stay
|
|
45
|
+
yours.
|
|
32
46
|
|
|
33
47
|
## Forms
|
|
34
48
|
|
|
@@ -56,7 +70,7 @@ for layer in layers: # 32 layers
|
|
|
56
70
|
```
|
|
57
71
|
|
|
58
72
|
```jsonc
|
|
59
|
-
{"layer
|
|
73
|
+
{"time_ms/layer": 412.8, "count/layer": 32}
|
|
60
74
|
```
|
|
61
75
|
|
|
62
76
|
The total is usually what you want; divide by the count for the mean.
|
|
@@ -92,7 +106,7 @@ already handles it:
|
|
|
92
106
|
```python
|
|
93
107
|
et.init(..., alert_rules=[
|
|
94
108
|
"mean(data/load_ms[50]) > 200 => warning: data loading is slowing down",
|
|
95
|
-
"forward
|
|
109
|
+
"time_ms/forward > 3 * mean(time_ms/forward[100]) => error: slow step",
|
|
96
110
|
])
|
|
97
111
|
```
|
|
98
112
|
|
|
@@ -215,9 +229,9 @@ with et.span("forward", plugins=[CpuTime(), TorchMemory()]):
|
|
|
215
229
|
|
|
216
230
|
```python
|
|
217
231
|
et.history(1)[0]
|
|
218
|
-
# {"forward
|
|
219
|
-
# "forward
|
|
220
|
-
# "forward
|
|
232
|
+
# {"time_ms/forward": 41.2, "count/forward": 1,
|
|
233
|
+
# "cpu_percent/forward": 101.2, "cpu_time_ms/forward": 41.4,
|
|
234
|
+
# "gpu_mem_peak_mb/forward": 456.1, "gpu_mem_delta_mb/forward": 8.1, ...}
|
|
221
235
|
```
|
|
222
236
|
|
|
223
237
|
Like `print_fn`, plugins are inherited by children, can be overridden per span,
|
|
@@ -227,8 +241,10 @@ code being measured, and a plugin that fails on every span is logged only once.
|
|
|
227
241
|
Plugin metrics also reach `spans.jsonl` and show up in the `et trace` viewer
|
|
228
242
|
under a span's *Arguments*.
|
|
229
243
|
|
|
230
|
-
`
|
|
231
|
-
|
|
244
|
+
`time_ms` and `count` belong to the span. A plugin returning either has that key
|
|
245
|
+
dropped with a warning, so nothing can quietly redefine what a span measured. Any
|
|
246
|
+
other key becomes its own group: a plugin returning `gpu_mem_peak_mb` writes
|
|
247
|
+
`gpu_mem_peak_mb/<path>`.
|
|
232
248
|
|
|
233
249
|
### Built-ins
|
|
234
250
|
|
|
@@ -16,6 +16,7 @@ et.init(
|
|
|
16
16
|
name: str | None = None,
|
|
17
17
|
entity: str | None = None,
|
|
18
18
|
dir: str | None = None,
|
|
19
|
+
run_dir: str | None = None,
|
|
19
20
|
notes: str | None = None,
|
|
20
21
|
tags: list[str] | None = None,
|
|
21
22
|
resume: bool | str | None = "allow",
|
|
@@ -33,9 +34,15 @@ et.init(
|
|
|
33
34
|
```
|
|
34
35
|
|
|
35
36
|
Starts a run and publishes it as the process-wide current run. `name` defaults to a
|
|
36
|
-
timestamp.
|
|
37
|
+
timestamp. Extra keyword arguments are
|
|
37
38
|
[history options](configuration.md#history-options).
|
|
38
39
|
|
|
40
|
+
`dir` is a root and defaults to `./tracker/jsonl`, so the run lands in
|
|
41
|
+
`<dir>/<project>/<name>` and artifacts in `<dir>/<project>/artifacts`. `run_dir`
|
|
42
|
+
names this run's directory outright, putting its artifacts in
|
|
43
|
+
`<run_dir>/artifacts`; passing both is an error. The resolved path is logged at
|
|
44
|
+
`init()` and available as `Run.dir`.
|
|
45
|
+
|
|
39
46
|
`resume` is forwarded to wandb and trackio. The local history always continues an
|
|
40
47
|
existing run directory, so re-running with the same project and name picks up the
|
|
41
48
|
step cursor whatever `resume` says; use a new name to start clean.
|
|
@@ -174,8 +181,10 @@ span.metrics # what the plugins measured
|
|
|
174
181
|
```
|
|
175
182
|
|
|
176
183
|
`et.span` is also an async context manager and a decorator. A closed span adds
|
|
177
|
-
|
|
178
|
-
|
|
184
|
+
`time_ms/<path>`, `count/<path>` and one key per plugin metric to the open row,
|
|
185
|
+
and appends the full record to `spans.jsonl`. The measurement leads the name so
|
|
186
|
+
that a UI groups every timing together instead of filing each one under whatever
|
|
187
|
+
it timed; `Span.duration_ms` is the same number on the object.
|
|
179
188
|
|
|
180
189
|
`print_fn(line)` announces the span's start and end, indented two spaces per level.
|
|
181
190
|
`plugins` measure a resource across the span; both are inherited by child spans
|
|
@@ -65,15 +65,17 @@ def main(argv=None) -> Path:
|
|
|
65
65
|
et.log({"train/loss": 1.0 / (step + 1)})
|
|
66
66
|
|
|
67
67
|
# Timings are ordinary metrics, so they query like any other
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
# A span's timing is named "<measurement>/<span path>", so every timing in
|
|
69
|
+
# the run shares one "time_ms" group instead of polluting the metric tree
|
|
70
|
+
rows = et.history(-1, metrics=["time_ms/step", "time_ms/step/data/read"])
|
|
71
|
+
total = sum(row["time_ms/step"] for row in rows) / len(rows)
|
|
72
|
+
waiting = sum(row["time_ms/step/data/read"] for row in rows) / len(rows)
|
|
71
73
|
print(f"\nmean step {total:.2f}ms, of which {waiting:.2f}ms waiting on IO")
|
|
72
74
|
print(f"the data loader is {100 * waiting / total:.0f}% of the step")
|
|
73
75
|
|
|
74
76
|
last = et.history(1)[0]
|
|
75
77
|
print("\nwhat one step recorded:")
|
|
76
|
-
for key in sorted(k for k in last if k.
|
|
78
|
+
for key in sorted(k for k in last if k.startswith(("time_ms/", "cpu_percent/"))):
|
|
77
79
|
print(f" {key:<38} {last[key]:.2f}")
|
|
78
80
|
|
|
79
81
|
et.finish()
|
|
@@ -82,9 +84,7 @@ def main(argv=None) -> Path:
|
|
|
82
84
|
print(f"\n{spans} spans -> {output}")
|
|
83
85
|
print("Open it at https://ui.perfetto.dev")
|
|
84
86
|
print("Timings are metrics, so they alert too:")
|
|
85
|
-
print(
|
|
86
|
-
' et.init(..., alert_rules=["step/duration_ms > 500 => warning: slow step"])'
|
|
87
|
-
)
|
|
87
|
+
print(' alert_rules=["time_ms/step > 500 => warning: slow step"]')
|
|
88
88
|
return output
|
|
89
89
|
|
|
90
90
|
|
|
@@ -8,7 +8,9 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import os
|
|
10
10
|
import re
|
|
11
|
+
from pathlib import Path
|
|
11
12
|
|
|
13
|
+
DEFAULT_ROOT = "./tracker/jsonl"
|
|
12
14
|
STREAM_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
|
13
15
|
RANK_PATTERN = re.compile(r"rank\d+")
|
|
14
16
|
|
|
@@ -72,3 +74,37 @@ def parse_stream(filename: str) -> str | None:
|
|
|
72
74
|
if parts and RANK_PATTERN.fullmatch(parts[-1]):
|
|
73
75
|
parts = parts[:-1]
|
|
74
76
|
return parts[0] if parts else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_log_dir(
|
|
80
|
+
project: str, name: str, dir: str | None = None, run_dir: str | None = None
|
|
81
|
+
) -> Path:
|
|
82
|
+
"""Where this run's files go.
|
|
83
|
+
|
|
84
|
+
``dir`` is a root holding many projects, so a run lands in
|
|
85
|
+
``<dir>/<project>/<name>``. That layout is what lets artifacts be shared and
|
|
86
|
+
deduplicated across a project's runs, and what lets a resume find its files
|
|
87
|
+
from the project and name alone. ``run_dir`` opts out of it and names the
|
|
88
|
+
directory outright, for when something else already chose the path.
|
|
89
|
+
"""
|
|
90
|
+
if run_dir is not None:
|
|
91
|
+
if dir is not None:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
"Pass either dir (a root, giving <dir>/<project>/<name>) or "
|
|
94
|
+
"run_dir (this run's directory), not both"
|
|
95
|
+
)
|
|
96
|
+
return Path(run_dir)
|
|
97
|
+
return Path(dir or DEFAULT_ROOT) / project / name
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def resolve_artifact_root(
|
|
101
|
+
project: str, dir: str | None = None, run_dir: str | None = None
|
|
102
|
+
) -> Path:
|
|
103
|
+
"""Where artifacts live: beside the project's runs, or inside a lone run.
|
|
104
|
+
|
|
105
|
+
A project-level store is what makes deduplication across runs possible, so
|
|
106
|
+
``run_dir`` gives up that sharing in exchange for a self-contained directory.
|
|
107
|
+
"""
|
|
108
|
+
if run_dir is not None:
|
|
109
|
+
return Path(run_dir) / "artifacts"
|
|
110
|
+
return Path(dir or DEFAULT_ROOT) / project / "artifacts"
|
|
@@ -27,6 +27,7 @@ from .codec import RESERVED_KEYS, RecordCodec, encode_line
|
|
|
27
27
|
from .frame import project, to_output
|
|
28
28
|
from .naming import (
|
|
29
29
|
metrics_filename,
|
|
30
|
+
resolve_log_dir,
|
|
30
31
|
sidecar_filename,
|
|
31
32
|
spans_filename,
|
|
32
33
|
validate_stream,
|
|
@@ -184,6 +185,7 @@ class HistoryStore:
|
|
|
184
185
|
name: str | None = None,
|
|
185
186
|
config: dict | None = None,
|
|
186
187
|
dir: str | None = None,
|
|
188
|
+
run_dir: str | None = None,
|
|
187
189
|
on_commit: Callable[[dict], None] | None = None,
|
|
188
190
|
**options,
|
|
189
191
|
) -> HistoryStore:
|
|
@@ -193,7 +195,7 @@ class HistoryStore:
|
|
|
193
195
|
name = time.strftime("run-%Y%m%d-%H%M%S")
|
|
194
196
|
logger.warning(f"No run name provided, using generated name: {name}")
|
|
195
197
|
self.project, self.name = project, name
|
|
196
|
-
self.log_dir =
|
|
198
|
+
self.log_dir = resolve_log_dir(project, name, dir, run_dir)
|
|
197
199
|
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
198
200
|
self.stream = opts.stream
|
|
199
201
|
self.config_fp = self.log_dir / sidecar_filename("config", opts.stream, "json")
|
|
@@ -221,6 +223,10 @@ class HistoryStore:
|
|
|
221
223
|
self._resume(opts.alert_window)
|
|
222
224
|
self._write_config(config)
|
|
223
225
|
self._register_atexit()
|
|
226
|
+
# Say where the files went: dir is a root, so the run is two levels below
|
|
227
|
+
# what the caller passed, and guessing that is nobody's idea of fun
|
|
228
|
+
resumed = f", resuming at step {self._next_step}" if self._next_step else ""
|
|
229
|
+
logger.info(f"Run {self.name!r} -> {self.log_dir}{resumed}")
|
|
224
230
|
return self
|
|
225
231
|
|
|
226
232
|
def _close_previous(self):
|
|
@@ -10,14 +10,13 @@ import os
|
|
|
10
10
|
import threading
|
|
11
11
|
import time
|
|
12
12
|
from collections.abc import Sequence
|
|
13
|
-
from pathlib import Path
|
|
14
13
|
from typing import Any, Literal
|
|
15
14
|
|
|
16
15
|
from loguru import logger
|
|
17
16
|
|
|
18
17
|
from .artifacts import Artifact, ArtifactStore, coerce_artifact
|
|
19
18
|
from .history import HistoryStore, current_rank, resolve_commit
|
|
20
|
-
from .history.naming import sidecar_filename
|
|
19
|
+
from .history.naming import resolve_artifact_root, sidecar_filename
|
|
21
20
|
from .summary import Summary
|
|
22
21
|
|
|
23
22
|
_lock = threading.RLock()
|
|
@@ -63,6 +62,7 @@ class Run:
|
|
|
63
62
|
name: str | None = None,
|
|
64
63
|
entity: str | None = None,
|
|
65
64
|
dir: str | None = None,
|
|
65
|
+
run_dir: str | None = None,
|
|
66
66
|
notes: str | None = None,
|
|
67
67
|
tags: list[str] | None = None,
|
|
68
68
|
resume: bool | str | None = "allow",
|
|
@@ -97,6 +97,7 @@ class Run:
|
|
|
97
97
|
name=name,
|
|
98
98
|
config=self.config or None,
|
|
99
99
|
dir=dir,
|
|
100
|
+
run_dir=run_dir,
|
|
100
101
|
print_to_screen=print_to_screen,
|
|
101
102
|
on_commit=self._on_commit,
|
|
102
103
|
stream=stream,
|
|
@@ -113,7 +114,7 @@ class Run:
|
|
|
113
114
|
atexit.register(self._save_summary_at_exit)
|
|
114
115
|
self._closers.append(lambda: atexit.unregister(self._save_summary_at_exit))
|
|
115
116
|
self.artifacts = ArtifactStore(
|
|
116
|
-
root=
|
|
117
|
+
root=resolve_artifact_root(project, dir, run_dir)
|
|
117
118
|
)
|
|
118
119
|
|
|
119
120
|
self.rank = current_rank()
|
|
@@ -23,9 +23,9 @@ from loguru import logger
|
|
|
23
23
|
# and each asyncio task gets its own copy, which is exactly span nesting.
|
|
24
24
|
_STACK: ContextVar[tuple[Span, ...]] = ContextVar("et_span_stack", default=())
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
RESERVED_METRICS = frozenset({
|
|
26
|
+
TIME_METRIC = "time_ms"
|
|
27
|
+
COUNT_METRIC = "count"
|
|
28
|
+
RESERVED_METRICS = frozenset({TIME_METRIC, COUNT_METRIC})
|
|
29
29
|
# Spaces, not a tab: a terminal measures tab stops from the start of the line, so
|
|
30
30
|
# behind a log prefix the first level collapses to whatever is left of the stop.
|
|
31
31
|
# Two spaces render the same width wherever the line begins.
|
|
@@ -45,6 +45,19 @@ def _warn_once(*key: str) -> None:
|
|
|
45
45
|
logger.warning(key[-1])
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
def span_metric(metric: str, path: str) -> str:
|
|
49
|
+
"""Name a span's measurement: what was measured first, then what it measured.
|
|
50
|
+
|
|
51
|
+
``time_ms/step/forward`` rather than ``step/forward/time_ms``, because a
|
|
52
|
+
tracker UI groups metrics by the segment before the first slash. With the
|
|
53
|
+
path first, every timing lands in the group of whatever it timed, next to
|
|
54
|
+
that name's real metrics -- a span called ``train`` would file its duration
|
|
55
|
+
beside ``train/loss``. With the measurement first, all timings share one
|
|
56
|
+
group and the metric namespace stays the caller's.
|
|
57
|
+
"""
|
|
58
|
+
return f"{metric}/{path}"
|
|
59
|
+
|
|
60
|
+
|
|
48
61
|
def _safely(plugin: Any, hook: str, span: Span):
|
|
49
62
|
"""Call a plugin hook; a broken plugin must not break the measured code."""
|
|
50
63
|
method = getattr(plugin, hook, None)
|
|
@@ -233,9 +246,12 @@ class Span:
|
|
|
233
246
|
if self._store is None:
|
|
234
247
|
return
|
|
235
248
|
metrics = {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
**{
|
|
249
|
+
span_metric(TIME_METRIC, self.path): self.duration_ms,
|
|
250
|
+
span_metric(COUNT_METRIC, self.path): 1,
|
|
251
|
+
**{
|
|
252
|
+
span_metric(key, self.path): value
|
|
253
|
+
for key, value in self.metrics.items()
|
|
254
|
+
},
|
|
239
255
|
}
|
|
240
256
|
record = {
|
|
241
257
|
"name": self.path,
|
|
@@ -23,6 +23,7 @@ def init(
|
|
|
23
23
|
name: str | None = None,
|
|
24
24
|
entity: str | None = None,
|
|
25
25
|
dir: str | None = None,
|
|
26
|
+
run_dir: str | None = None,
|
|
26
27
|
notes: str | None = None,
|
|
27
28
|
tags: list[str] | None = None,
|
|
28
29
|
resume: bool | Literal["allow", "never", "must", "auto"] | None = "allow",
|
|
@@ -36,6 +37,10 @@ def init(
|
|
|
36
37
|
) -> Run:
|
|
37
38
|
"""Initialise the tracker. Local jsonl history is always on, whatever ``backends``.
|
|
38
39
|
|
|
40
|
+
``dir`` is a root holding many projects, so the run lands in
|
|
41
|
+
``<dir>/<project>/<name>``. Pass ``run_dir`` instead to name the directory
|
|
42
|
+
outright.
|
|
43
|
+
|
|
39
44
|
Extra keyword arguments are forwarded to the history store (``cache_bytes``,
|
|
40
45
|
``alert_window``, ``max_open_seconds``, ``step_policy``, ``buffer_size``, ...).
|
|
41
46
|
"""
|
|
@@ -48,6 +53,7 @@ def init(
|
|
|
48
53
|
name=name,
|
|
49
54
|
entity=entity,
|
|
50
55
|
dir=dir,
|
|
56
|
+
run_dir=run_dir,
|
|
51
57
|
notes=notes,
|
|
52
58
|
tags=tags,
|
|
53
59
|
resume=resume,
|
|
@@ -310,14 +310,14 @@ def test_profiling_records_the_span_tree_as_metrics(profile_step, tmp_path):
|
|
|
310
310
|
output = profile_step.main(argv(**{"--steps": 5, "--dir": tmp_path}))
|
|
311
311
|
row = read_history(output.parent, 1)[0]
|
|
312
312
|
for key in (
|
|
313
|
-
"step
|
|
314
|
-
"step/data/read
|
|
315
|
-
"step/data/collate
|
|
316
|
-
"step/forward
|
|
317
|
-
"step/backward
|
|
313
|
+
"time_ms/step",
|
|
314
|
+
"time_ms/step/data/read",
|
|
315
|
+
"time_ms/step/data/collate",
|
|
316
|
+
"time_ms/step/forward",
|
|
317
|
+
"time_ms/step/backward",
|
|
318
318
|
):
|
|
319
319
|
assert key in row, key
|
|
320
|
-
assert row["step
|
|
320
|
+
assert row["time_ms/step"] >= row["time_ms/step/forward"]
|
|
321
321
|
|
|
322
322
|
|
|
323
323
|
def test_profiling_attaches_the_cpu_plugin_to_every_span(profile_step, tmp_path):
|
|
@@ -325,8 +325,8 @@ def test_profiling_attaches_the_cpu_plugin_to_every_span(profile_step, tmp_path)
|
|
|
325
325
|
|
|
326
326
|
output = profile_step.main(argv(**{"--steps": 5, "--dir": tmp_path}))
|
|
327
327
|
row = read_history(output.parent, 1)[0]
|
|
328
|
-
assert row["step/forward
|
|
329
|
-
assert row["step/data/read
|
|
328
|
+
assert row["cpu_percent/step/forward"] > 50 # a spin loop
|
|
329
|
+
assert row["cpu_percent/step/data/read"] < 50 # a sleep
|
|
330
330
|
|
|
331
331
|
|
|
332
332
|
def test_profiling_writes_a_loadable_trace(profile_step, tmp_path):
|