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.
Files changed (113) hide show
  1. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/PKG-INFO +1 -1
  2. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/design.md +7 -0
  3. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/getting-started.md +26 -7
  4. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/artifacts.md +11 -0
  5. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/spans.md +29 -13
  6. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/reference/api.md +12 -3
  7. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/examples/profile_step.py +7 -7
  8. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/history/naming.py +36 -0
  9. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/history/store.py +7 -1
  10. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/run.py +4 -3
  11. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/spans.py +22 -6
  12. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/tracker.py +6 -0
  13. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_examples.py +8 -8
  14. expr_tracker-0.3.0/tests/test_run_dir.py +214 -0
  15. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_span_plugins.py +16 -15
  16. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_spans.py +113 -33
  17. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/.github/workflows/docs.yaml +0 -0
  18. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/.github/workflows/release.yaml +0 -0
  19. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/.gitignore +0 -0
  20. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/LICENSE +0 -0
  21. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/README.md +0 -0
  22. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/architecture.md +0 -0
  23. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/examples.md +0 -0
  24. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/alerts.md +0 -0
  25. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/backends.md +0 -0
  26. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/cli.md +0 -0
  27. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/distributed.md +0 -0
  28. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/history.md +0 -0
  29. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/logging.md +0 -0
  30. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/guide/streams.md +0 -0
  31. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/index.md +0 -0
  32. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/reference/configuration.md +0 -0
  33. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/docs/reference/expressions.md +0 -0
  34. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/examples/README.md +0 -0
  35. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/examples/alert_rules.py +0 -0
  36. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/examples/checkpoints.py +0 -0
  37. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/examples/early_stopping.py +0 -0
  38. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/examples/multiprocess_pipeline.py +0 -0
  39. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/examples/quickstart.py +0 -0
  40. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/mkdocs.yml +0 -0
  41. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/pyproject.toml +0 -0
  42. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/__init__.py +0 -0
  43. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/_compat.py +0 -0
  44. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/__init__.py +0 -0
  45. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/backends/__init__.py +0 -0
  46. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/backends/base.py +0 -0
  47. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/backends/cards.py +0 -0
  48. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/dispatch.py +0 -0
  49. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/engine.py +0 -0
  50. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/expr/__init__.py +0 -0
  51. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/expr/eval.py +0 -0
  52. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/expr/functions.py +0 -0
  53. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/expr/lexer.py +0 -0
  54. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/expr/nodes.py +0 -0
  55. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/expr/parser.py +0 -0
  56. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/expr/rule.py +0 -0
  57. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/alerts/models.py +0 -0
  58. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/artifacts.py +0 -0
  59. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/cli.py +0 -0
  60. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/encoders.py +0 -0
  61. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/history/__init__.py +0 -0
  62. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/history/codec.py +0 -0
  63. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/history/frame.py +0 -0
  64. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/history/reader.py +0 -0
  65. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/history/series.py +0 -0
  66. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/history/writer.py +0 -0
  67. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/plugins.py +0 -0
  68. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/py.typed +0 -0
  69. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/summary.py +0 -0
  70. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/trace.py +0 -0
  71. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/src/expr_tracker/types.py +0 -0
  72. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/conftest.py +0 -0
  73. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_alert_backends.py +0 -0
  74. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_alert_delivery.py +0 -0
  75. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_alert_dispatch.py +0 -0
  76. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_alert_engine.py +0 -0
  77. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_alert_models.py +0 -0
  78. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_alert_routing.py +0 -0
  79. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_artifacts.py +0 -0
  80. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_benchmark.py +0 -0
  81. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_cache.py +0 -0
  82. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_cli.py +0 -0
  83. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_correctness.py +0 -0
  84. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_distributed.py +0 -0
  85. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_e2e.py +0 -0
  86. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_expr_builder.py +0 -0
  87. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_expr_eval.py +0 -0
  88. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_expr_functions.py +0 -0
  89. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_expr_parser.py +0 -0
  90. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_expr_properties.py +0 -0
  91. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_failure_modes.py +0 -0
  92. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_features.py +0 -0
  93. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_frame_codec_summary.py +0 -0
  94. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_history.py +0 -0
  95. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_hot_paths.py +0 -0
  96. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_integration.py +0 -0
  97. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_lark.py +0 -0
  98. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_perf.py +0 -0
  99. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_public_surfaces.py +0 -0
  100. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_review_regressions.py +0 -0
  101. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_rule_lifecycle.py +0 -0
  102. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_run_backends.py +0 -0
  103. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_scenarios.py +0 -0
  104. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_step_commit.py +0 -0
  105. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_streams.py +0 -0
  106. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_stress.py +0 -0
  107. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_trace.py +0 -0
  108. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_trackio.py +0 -0
  109. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_value_encoding.py +0 -0
  110. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_wandb.py +0 -0
  111. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_writer_buffer.py +0 -0
  112. {expr_tracker-0.2.7 → expr_tracker-0.3.0}/tests/test_writer_durability.py +0 -0
  113. {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.2.7
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/mnist/baseline/
52
- ├── metrics.jsonl # one JSON object per step
53
- ├── metrics.meta.json # index sidecar, for fast resume and seeks
54
- ├── config.json
55
- ├── summary.json
56
- └── artifacts.jsonl # lineage: what this run produced and consumed
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
- Change the location with `et.init(dir="/data/runs")`.
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/duration_ms": 31.2, "forward/count": 1,
24
- "forward/attention/duration_ms": 18.4, "forward/attention/count": 1,
25
- "forward/mlp/duration_ms": 9.1, "forward/mlp/count": 1,
26
- "backward/duration_ms": 44.7, "backward/count": 1,
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
- Nested names join with `/`, so `forward/norm` and `backward/norm` stay distinct,
31
- and the alert language reads them directly.
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/duration_ms": 412.8, "layer/count": 32}
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/duration_ms > 3 * mean(forward/duration_ms[100]) => error: slow step",
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/duration_ms": 41.2, "forward/count": 1,
219
- # "forward/cpu_percent": 101.2, "forward/cpu_time_ms": 41.4,
220
- # "forward/gpu_mem_peak_mb": 456.1, "forward/gpu_mem_delta_mb": 8.1, ...}
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
- `duration_ms` and `count` belong to the span. A plugin returning either has that
231
- key dropped with a warning, so nothing can quietly redefine what a span measured.
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. `dir` defaults to `./tracker/jsonl`. Extra keyword arguments are
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
- `<path>/duration_ms`, `<path>/count` and one key per plugin metric to the open
178
- row, and appends the full record to `spans.jsonl`.
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
- rows = et.history(-1, metrics=["step/duration_ms", "step/data/read/duration_ms"])
69
- total = sum(row["step/duration_ms"] for row in rows) / len(rows)
70
- waiting = sum(row["step/data/read/duration_ms"] for row in rows) / len(rows)
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.endswith(("duration_ms", "cpu_percent"))):
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 = Path(dir or "./tracker/jsonl") / project / name
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=Path(dir or "./tracker/jsonl") / project / "artifacts"
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
- DURATION_SUFFIX = "duration_ms"
27
- COUNT_SUFFIX = "count"
28
- RESERVED_METRICS = frozenset({DURATION_SUFFIX, COUNT_SUFFIX})
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
- f"{self.path}/{DURATION_SUFFIX}": self.duration_ms,
237
- f"{self.path}/{COUNT_SUFFIX}": 1,
238
- **{f"{self.path}/{key}": value for key, value in self.metrics.items()},
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/duration_ms",
314
- "step/data/read/duration_ms",
315
- "step/data/collate/duration_ms",
316
- "step/forward/duration_ms",
317
- "step/backward/duration_ms",
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/duration_ms"] >= row["step/forward/duration_ms"]
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/cpu_percent"] > 50 # a spin loop
329
- assert row["step/data/read/cpu_percent"] < 50 # a sleep
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):