expr-tracker 0.2.3__tar.gz → 0.2.5__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 (112) hide show
  1. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/.github/workflows/docs.yaml +1 -0
  2. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/PKG-INFO +28 -4
  3. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/README.md +23 -2
  4. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/docs/design.md +27 -0
  5. expr_tracker-0.2.5/docs/examples.md +10 -0
  6. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/docs/getting-started.md +4 -0
  7. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/docs/guide/cli.md +2 -0
  8. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/docs/guide/distributed.md +25 -2
  9. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/docs/guide/history.md +8 -0
  10. expr_tracker-0.2.5/docs/guide/spans.md +289 -0
  11. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/docs/guide/streams.md +33 -0
  12. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/docs/index.md +1 -0
  13. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/docs/reference/api.md +35 -4
  14. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/docs/reference/configuration.md +2 -0
  15. expr_tracker-0.2.5/examples/README.md +65 -0
  16. expr_tracker-0.2.5/examples/alert_rules.py +115 -0
  17. expr_tracker-0.2.5/examples/checkpoints.py +83 -0
  18. expr_tracker-0.2.5/examples/early_stopping.py +99 -0
  19. expr_tracker-0.2.5/examples/multiprocess_pipeline.py +205 -0
  20. expr_tracker-0.2.5/examples/profile_step.py +92 -0
  21. expr_tracker-0.2.5/examples/quickstart.py +90 -0
  22. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/mkdocs.yml +4 -0
  23. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/pyproject.toml +2 -0
  24. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/__init__.py +2 -1
  25. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/cli.py +14 -1
  26. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/history/store.py +4 -0
  27. expr_tracker-0.2.5/src/expr_tracker/plugins.py +269 -0
  28. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/run.py +20 -6
  29. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/spans.py +142 -14
  30. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/trace.py +1 -0
  31. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_cli.py +43 -0
  32. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_distributed.py +106 -0
  33. expr_tracker-0.2.5/tests/test_examples.py +383 -0
  34. expr_tracker-0.2.5/tests/test_span_plugins.py +1163 -0
  35. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/uv.lock +16 -1
  36. expr_tracker-0.2.3/docs/guide/spans.md +0 -169
  37. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/.github/workflows/release.yaml +0 -0
  38. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/.gitignore +0 -0
  39. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/LICENSE +0 -0
  40. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/docs/architecture.md +0 -0
  41. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/docs/guide/alerts.md +0 -0
  42. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/docs/guide/artifacts.md +0 -0
  43. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/docs/guide/backends.md +0 -0
  44. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/docs/guide/logging.md +0 -0
  45. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/docs/reference/expressions.md +0 -0
  46. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/_compat.py +0 -0
  47. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/alerts/__init__.py +0 -0
  48. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/alerts/backends/__init__.py +0 -0
  49. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/alerts/backends/base.py +0 -0
  50. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/alerts/dispatch.py +0 -0
  51. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/alerts/engine.py +0 -0
  52. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/alerts/expr/__init__.py +0 -0
  53. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/alerts/expr/eval.py +0 -0
  54. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/alerts/expr/functions.py +0 -0
  55. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/alerts/expr/lexer.py +0 -0
  56. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/alerts/expr/nodes.py +0 -0
  57. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/alerts/expr/parser.py +0 -0
  58. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/alerts/expr/rule.py +0 -0
  59. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/alerts/models.py +0 -0
  60. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/artifacts.py +0 -0
  61. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/encoders.py +0 -0
  62. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/history/__init__.py +0 -0
  63. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/history/codec.py +0 -0
  64. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/history/frame.py +0 -0
  65. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/history/naming.py +0 -0
  66. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/history/reader.py +0 -0
  67. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/history/series.py +0 -0
  68. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/history/writer.py +0 -0
  69. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/py.typed +0 -0
  70. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/summary.py +0 -0
  71. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/tracker.py +0 -0
  72. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/src/expr_tracker/types.py +0 -0
  73. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/conftest.py +0 -0
  74. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_alert_backends.py +0 -0
  75. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_alert_delivery.py +0 -0
  76. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_alert_dispatch.py +0 -0
  77. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_alert_engine.py +0 -0
  78. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_alert_models.py +0 -0
  79. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_alert_routing.py +0 -0
  80. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_artifacts.py +0 -0
  81. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_benchmark.py +0 -0
  82. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_cache.py +0 -0
  83. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_correctness.py +0 -0
  84. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_e2e.py +0 -0
  85. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_expr_builder.py +0 -0
  86. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_expr_eval.py +0 -0
  87. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_expr_functions.py +0 -0
  88. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_expr_parser.py +0 -0
  89. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_expr_properties.py +0 -0
  90. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_failure_modes.py +0 -0
  91. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_features.py +0 -0
  92. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_frame_codec_summary.py +0 -0
  93. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_history.py +0 -0
  94. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_hot_paths.py +0 -0
  95. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_integration.py +0 -0
  96. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_lark_live.py +0 -0
  97. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_perf.py +0 -0
  98. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_public_surfaces.py +0 -0
  99. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_review_regressions.py +0 -0
  100. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_rule_lifecycle.py +0 -0
  101. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_run_backends.py +0 -0
  102. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_scenarios.py +0 -0
  103. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_spans.py +0 -0
  104. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_step_commit.py +0 -0
  105. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_streams.py +0 -0
  106. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_stress.py +0 -0
  107. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_trace.py +0 -0
  108. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_trackio.py +0 -0
  109. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_value_encoding.py +0 -0
  110. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_wandb.py +0 -0
  111. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_writer_buffer.py +0 -0
  112. {expr_tracker-0.2.3 → expr_tracker-0.2.5}/tests/test_writer_durability.py +0 -0
@@ -5,6 +5,7 @@ on:
5
5
  branches: [main]
6
6
  paths:
7
7
  - "docs/**"
8
+ - "examples/**" # docs/examples.md includes examples/README.md
8
9
  - "mkdocs.yml"
9
10
  - ".github/workflows/docs.yaml"
10
11
  workflow_dispatch:
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: expr_tracker
3
- Version: 0.2.3
3
+ Version: 0.2.5
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/
@@ -28,11 +28,14 @@ Requires-Dist: click>=8.1.0
28
28
  Requires-Dist: loguru>=0.7.3
29
29
  Requires-Dist: pydantic>=2.0
30
30
  Provides-Extra: all
31
+ Requires-Dist: nvidia-ml-py>=12.0; extra == 'all'
31
32
  Requires-Dist: pandas>=1.5; extra == 'all'
32
33
  Requires-Dist: polars>=0.20; extra == 'all'
33
34
  Requires-Dist: slark>=0.1.28; extra == 'all'
34
35
  Requires-Dist: trackio>=0.4.0; extra == 'all'
35
36
  Requires-Dist: wandb>=0.21.0; extra == 'all'
37
+ Provides-Extra: gpu
38
+ Requires-Dist: nvidia-ml-py>=12.0; extra == 'gpu'
36
39
  Provides-Extra: lark
37
40
  Requires-Dist: slark>=0.1.28; extra == 'lark'
38
41
  Provides-Extra: pandas
@@ -106,10 +109,29 @@ install command; it never crashes a run.
106
109
  | [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
110
  | [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
111
  | [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. |
112
+ | [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. `print_fn` prints the tree live, and plugins attach CPU and GPU cost to each region. |
110
113
  | [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. |
111
114
  | [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. |
112
- | [CLI](https://hspk.github.io/expr_tracker/guide/cli/) | `et history`, `et rules explain`, `et rules test`, `et alert`. |
115
+ | [CLI](https://hspk.github.io/expr_tracker/guide/cli/) | `et history`, `et trace`, `et rules explain`, `et rules test`, `et alert`. |
116
+
117
+ ## Examples
118
+
119
+ Every one runs offline, with no account and no network.
120
+
121
+ | | |
122
+ | --- | --- |
123
+ | [`quickstart.py`](examples/quickstart.py) | The sixty-second tour: log a run, merge eval into the training step, read the history back while it is open and again afterwards. |
124
+ | [`alert_rules.py`](examples/alert_rules.py) | Four rules against four faults — a loss spike, a non-finite loss, a stalled curve and a regression that must persist. `--fault none` fires nothing, which is the point. |
125
+ | [`profile_step.py`](examples/profile_step.py) | Where a step goes. Nested spans become metrics, a plugin adds CPU cost, and the tree exports to Perfetto. |
126
+ | [`early_stopping.py`](examples/early_stopping.py) | Querying your own history mid-run to decay the learning rate on a plateau and stop when it stops paying. |
127
+ | [`checkpoints.py`](examples/checkpoints.py) | Checkpoints as versioned artifacts, deduplicated by content and fetched later by alias. |
128
+ | [`multiprocess_pipeline.py`](examples/multiprocess_pipeline.py) | Four data producers and four trainers as eight processes in one run, with bounded staleness. Each worker gets its own stream and its own lane in the trace, and the blocking spans show which side is the bottleneck. |
129
+
130
+ ```bash
131
+ uv run python examples/quickstart.py
132
+ uv run python examples/alert_rules.py --fault spike
133
+ uv run python examples/multiprocess_pipeline.py --produce-ms 10 --train-ms 40
134
+ ```
113
135
 
114
136
  ## wandb compatibility
115
137
 
@@ -152,6 +174,8 @@ uv run ruff format src tests
152
174
  | `test_expr_properties.py` | DSL properties: render round-trip stability, precedence, the whole `M` builder |
153
175
  | `test_trace.py` | Chrome Trace export: lane layout, stream and step selection, the CLI |
154
176
  | `test_spans.py` | nesting, aggregation, decorator and async forms, errors, thread and task isolation |
177
+ | `test_examples.py` | the shipped examples run, and their backpressure claims hold |
178
+ | `test_span_plugins.py` | `print_fn` output and indentation, the plugin protocol, failure isolation, CPU/GPU built-ins |
155
179
  | `test_streams.py` | stream naming and validation, isolation, resolution order, backend grouping, two-process runs |
156
180
  | `test_distributed.py` | rank shards, `alert_on_rank`, real multi-process runs |
157
181
  | `test_wandb.py` | real wandb in offline mode: parameter mapping, step alignment, artifacts |
@@ -59,10 +59,29 @@ install command; it never crashes a run.
59
59
  | [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. |
60
60
  | [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. |
61
61
  | [Artifacts](https://hspk.github.io/expr_tracker/guide/artifacts/) | Versioned file sets, deduplicated by content, shared across a project's runs, with lineage. |
62
- | [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. |
62
+ | [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. `print_fn` prints the tree live, and plugins attach CPU and GPU cost to each region. |
63
63
  | [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. |
64
64
  | [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. |
65
- | [CLI](https://hspk.github.io/expr_tracker/guide/cli/) | `et history`, `et rules explain`, `et rules test`, `et alert`. |
65
+ | [CLI](https://hspk.github.io/expr_tracker/guide/cli/) | `et history`, `et trace`, `et rules explain`, `et rules test`, `et alert`. |
66
+
67
+ ## Examples
68
+
69
+ Every one runs offline, with no account and no network.
70
+
71
+ | | |
72
+ | --- | --- |
73
+ | [`quickstart.py`](examples/quickstart.py) | The sixty-second tour: log a run, merge eval into the training step, read the history back while it is open and again afterwards. |
74
+ | [`alert_rules.py`](examples/alert_rules.py) | Four rules against four faults — a loss spike, a non-finite loss, a stalled curve and a regression that must persist. `--fault none` fires nothing, which is the point. |
75
+ | [`profile_step.py`](examples/profile_step.py) | Where a step goes. Nested spans become metrics, a plugin adds CPU cost, and the tree exports to Perfetto. |
76
+ | [`early_stopping.py`](examples/early_stopping.py) | Querying your own history mid-run to decay the learning rate on a plateau and stop when it stops paying. |
77
+ | [`checkpoints.py`](examples/checkpoints.py) | Checkpoints as versioned artifacts, deduplicated by content and fetched later by alias. |
78
+ | [`multiprocess_pipeline.py`](examples/multiprocess_pipeline.py) | Four data producers and four trainers as eight processes in one run, with bounded staleness. Each worker gets its own stream and its own lane in the trace, and the blocking spans show which side is the bottleneck. |
79
+
80
+ ```bash
81
+ uv run python examples/quickstart.py
82
+ uv run python examples/alert_rules.py --fault spike
83
+ uv run python examples/multiprocess_pipeline.py --produce-ms 10 --train-ms 40
84
+ ```
66
85
 
67
86
  ## wandb compatibility
68
87
 
@@ -105,6 +124,8 @@ uv run ruff format src tests
105
124
  | `test_expr_properties.py` | DSL properties: render round-trip stability, precedence, the whole `M` builder |
106
125
  | `test_trace.py` | Chrome Trace export: lane layout, stream and step selection, the CLI |
107
126
  | `test_spans.py` | nesting, aggregation, decorator and async forms, errors, thread and task isolation |
127
+ | `test_examples.py` | the shipped examples run, and their backpressure claims hold |
128
+ | `test_span_plugins.py` | `print_fn` output and indentation, the plugin protocol, failure isolation, CPU/GPU built-ins |
108
129
  | `test_streams.py` | stream naming and validation, isolation, resolution order, backend grouping, two-process runs |
109
130
  | `test_distributed.py` | rank shards, `alert_on_rank`, real multi-process runs |
110
131
  | `test_wandb.py` | real wandb in offline mode: parameter mapping, step alignment, artifacts |
@@ -111,11 +111,38 @@ so timing a region adds no row. The tree, with timestamps and attributes, goes t
111
111
  `spans[.stream][.rankN].jsonl` through a second `JsonlWriter`, enqueued rather
112
112
  than appended so a span does not pay for a flush decision of its own.
113
113
 
114
+ Plugins hang off the same two hooks. `start` runs before the clock starts and
115
+ `end` after it stops, so a plugin never inflates the duration it reports on; what
116
+ `end` returns is merged into the span's metrics under the span's own path, which
117
+ means a plugin needs no knowledge of the store, the codec or the accumulate rule.
118
+ A plugin that raises is logged and dropped: a measurement must not break the
119
+ thing it measures.
120
+
121
+ Peak-memory plugins have to fight the fact that `torch.cuda.max_memory_allocated`
122
+ is one global high-water mark. Resetting it per span is the only way to attribute
123
+ a peak, but a child's reset destroys its parent's history. `TorchMemory` keeps a
124
+ per-thread stack and, as each span closes, hands its parent back the peak the
125
+ parent had reached before that child opened, so `max(counter, carried)` is
126
+ correct at every level.
127
+
128
+ GPU utilisation cannot be attributed by reading NVML at a span boundary: the
129
+ counter is a rolling average over NVML's own window, so for a 3 ms span it
130
+ describes the wrong interval entirely. `GpuStats` samples in a background thread
131
+ and each span averages the samples that fall inside it, which also keeps the
132
+ NVML call off the measured path.
133
+
114
134
  The nesting stack is a `ContextVar`, not a `threading.local`: a new thread starts
115
135
  from the default and each asyncio task gets its own copy, which is exactly the
116
136
  scoping spans need. (The run singleton went the other way, from `ContextVar` to a
117
137
  locked global, because there the goal was for worker threads to *share* it.)
118
138
 
139
+ Closing a span filters itself out of the stack rather than resetting the token
140
+ it got when it opened. A token restores the entire stack as it was, so with
141
+ `start_span` — where spans need not close in the order they opened — an outer
142
+ span closing first would be put back by its inner span's reset, and everything
143
+ afterwards would nest under a span that had already ended. Filtering is exact,
144
+ and at ten levels deep it still does not show up against the ~15 µs a span costs.
145
+
119
146
  ### A.6 Multiple processes
120
147
 
121
148
  The supported model is **rank 0 tracks**; there is no cross-rank merging.
@@ -0,0 +1,10 @@
1
+ ---
2
+ title: Examples
3
+ ---
4
+
5
+ <!-- Single source: examples/README.md, so the two cannot drift apart. -->
6
+
7
+ --8<-- "examples/README.md"
8
+
9
+ The source lives in
10
+ [`examples/`](https://github.com/HSPK/expr_tracker/tree/main/examples).
@@ -85,12 +85,16 @@ et.get_run().step # 1000 - the cursor picked up where it left off
85
85
  len(et.history(-1)) # 1000 - the old rows are still there
86
86
  ```
87
87
 
88
+ This is unconditional: `resume` is passed on to wandb and trackio, but the local
89
+ file always continues. Use a different name to start clean.
90
+
88
91
  A process that dies without `finish()` still leaves a complete, valid file: the open
89
92
  row is committed and the summary saved by an exit hook, and a torn trailing line is
90
93
  repaired on the next run.
91
94
 
92
95
  ## Next
93
96
 
97
+ - [Examples](examples.md) — six runnable programs, all offline.
94
98
  - [Logging metrics](guide/logging.md) for commit semantics and out-of-order steps.
95
99
  - [Alerts](guide/alerts.md) to get notified instead of watching curves.
96
100
  - [CLI](guide/cli.md) to inspect runs without writing code.
@@ -11,6 +11,7 @@ et history tracker/jsonl/demo/run-1
11
11
  et history tracker/jsonl/demo/run-1 -n 50 --metrics loss,lr
12
12
  et history tracker/jsonl/demo/run-1 --step-range 100:200 --format json
13
13
  et history tracker/jsonl/demo/run-1 -n -1 --format csv > run.csv
14
+ et history runs/llm/sft-1 --stream data
14
15
  ```
15
16
 
16
17
  | Option | Default | Meaning |
@@ -18,6 +19,7 @@ et history tracker/jsonl/demo/run-1 -n -1 --format csv > run.csv
18
19
  | `-n` | 20 | number of steps, `-1` for all |
19
20
  | `--metrics` | all | comma-separated names |
20
21
  | `--step-range` | — | `start:end`, end exclusive; either side may be empty |
22
+ | `--stream` | the unnamed producer | which [stream](streams.md) to read |
21
23
  | `--format` | `table` | `table`, `json` or `csv` |
22
24
 
23
25
  The argument is a run directory or a `metrics.jsonl` file.
@@ -50,6 +50,29 @@ its rules, and reports them through `info()`:
50
50
  et.info()["rank"] # which rank this process was detected as
51
51
  ```
52
52
 
53
+ ## Remote backends
54
+
55
+ wandb and trackio identify a run by id, and neither can merge two step axes into
56
+ one run. If every rank opened the same id they would interleave their steps into
57
+ it — exactly what the local shards exist to prevent. So only rank 0 opens a
58
+ remote run by default:
59
+
60
+ ```python
61
+ et.init(..., backends=["wandb"]) # rank 0 reports (default)
62
+ et.init(..., backends=["wandb"], backend_on_rank=2) # rank 2 reports instead
63
+ et.init(..., backends=["wandb"], backend_on_rank=None) # every rank reports
64
+ ```
65
+
66
+ A silenced rank still writes its full local history; it simply never calls the
67
+ backend. When every rank does report, each gets its own run id and they are tied
68
+ together with `group`, which both backends understand:
69
+
70
+ | | id | group |
71
+ | --- | --- | --- |
72
+ | rank 0 | `sft-1` | `sft-1` |
73
+ | rank 2 | `sft-1-rank2` | `sft-1` |
74
+ | rank 2 of stream `data` | `sft-1-data-rank2` | `sft-1` |
75
+
53
76
  ## Typical setup
54
77
 
55
78
  ```python
@@ -64,5 +87,5 @@ et.init(
64
87
  )
65
88
  ```
66
89
 
67
- Every rank writes to `/shared/runs/llm/sft-<id>/`, each into its own shard, and only
68
- rank 0 pages you.
90
+ Every rank writes to `/shared/runs/llm/sft-<id>/`, each into its own shard. Only
91
+ rank 0 pages you, and only rank 0 opens the wandb run.
@@ -30,6 +30,14 @@ et.history(-1, metrics=["eval/acc"], dropna=True) # drop steps without it
30
30
  `dropna` drops a row when **all** selected metrics are missing, which is what you
31
31
  want for sparse eval metrics logged every N steps.
32
32
 
33
+ `n` selects rows first and `dropna` filters them afterwards. Asking for the last
34
+ 4 rows of a metric logged every 10 steps therefore finds nothing — take every
35
+ eval point and slice:
36
+
37
+ ```python
38
+ et.history(-1, metrics=["eval/acc"], dropna=True)[-4:] # the last 4 evals
39
+ ```
40
+
33
41
  ## Output types
34
42
 
35
43
  ```python
@@ -0,0 +1,289 @@
1
+ # Spans
2
+
3
+ A step is rarely one thing. `et.span` times the parts, and their parts, and turns
4
+ each duration into an ordinary metric — so `history()`, alert rules and plots all
5
+ work on it with no extra machinery.
6
+
7
+ ```python
8
+ for batch in loader:
9
+ with et.span("forward"):
10
+ with et.span("attention"):
11
+ attn = attention(batch)
12
+ with et.span("mlp"):
13
+ out = mlp(attn)
14
+ with et.span("backward"):
15
+ loss.backward()
16
+ et.log({"loss": loss.item()})
17
+ ```
18
+
19
+ The step's row then carries:
20
+
21
+ ```jsonc
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,
27
+ "loss": 0.31}
28
+ ```
29
+
30
+ Nested names join with `/`, so `forward/norm` and `backward/norm` stay distinct,
31
+ and the alert language reads them directly.
32
+
33
+ ## Forms
34
+
35
+ ```python
36
+ with et.span("forward"): ... # context manager
37
+
38
+ async with et.span("fetch"): ... # async
39
+
40
+ @et.span("preprocess") # decorator, sync or async
41
+ def preprocess(batch): ...
42
+
43
+ span = et.start_span("epoch") # manual, for crossing scopes
44
+ ...
45
+ span.end()
46
+ ```
47
+
48
+ ## Repeated spans
49
+
50
+ The same span several times in one step sums, and counts:
51
+
52
+ ```python
53
+ for layer in layers: # 32 layers
54
+ with et.span("layer"):
55
+ x = layer(x)
56
+ ```
57
+
58
+ ```jsonc
59
+ {"layer/duration_ms": 412.8, "layer/count": 32}
60
+ ```
61
+
62
+ The total is usually what you want; divide by the count for the mean.
63
+
64
+ ## A span never commits a step
65
+
66
+ Durations join whatever `log()` commits, so a span costs no row of its own and
67
+ you can time things before you know what to log:
68
+
69
+ ```python
70
+ with et.span("forward"):
71
+ ...
72
+ # nothing written yet
73
+ et.log({"loss": loss}) # one row, with the metrics and the durations
74
+ ```
75
+
76
+ ## Attributes
77
+
78
+ Attributes describe an individual span. They reach `spans.jsonl`, not the metrics,
79
+ because they are usually not numbers:
80
+
81
+ ```python
82
+ with et.span("load", batch_size=32) as span:
83
+ rows = read()
84
+ span.set(rows=len(rows))
85
+ ```
86
+
87
+ ## Alerting on durations
88
+
89
+ Because a duration is a metric, the [expression language](../reference/expressions.md)
90
+ already handles it:
91
+
92
+ ```python
93
+ et.init(..., alert_rules=[
94
+ "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",
96
+ ])
97
+ ```
98
+
99
+ ## The span file
100
+
101
+ The full tree is written to `spans.jsonl` beside the metrics, one line per span:
102
+
103
+ ```jsonc
104
+ {"_step": 42, "name": "forward/attention", "depth": 1,
105
+ "start": 1754323200.123456, "dur_ms": 18.4, "args": {"batch_size": 32}}
106
+ ```
107
+
108
+ Children appear before their parents, because a parent closes last. It follows
109
+ the [stream](streams.md): a data worker writes `spans.data.jsonl`.
110
+
111
+ Turn it off if you only want the metrics:
112
+
113
+ ```python
114
+ et.init(..., spans=False)
115
+ ```
116
+
117
+ ## Viewing the timeline
118
+
119
+ `et trace` turns the span file into a Chrome Trace, which
120
+ [Perfetto](https://ui.perfetto.dev) and `chrome://tracing` open directly:
121
+
122
+ ```bash
123
+ et trace runs/llm/sft-1 -o trace.json
124
+ et trace runs/llm/sft-1 --stream data --step-range 100:200
125
+ ```
126
+
127
+ Each stream becomes a process and each thread a track, so a data worker and a
128
+ training loop sit on one timeline and the gap where one waited for the other is
129
+ visible. Spans keep their nesting, their step and their attributes.
130
+
131
+ Exporting a standard format rather than drawing our own view means the result
132
+ can be loaded beside a `torch.profiler` trace, which is usually where the real
133
+ question is: what were the GPUs doing while the loader stalled.
134
+
135
+ ## Errors
136
+
137
+ An exception is recorded and re-raised — the span never swallows it:
138
+
139
+ ```jsonc
140
+ {"_step": 42, "name": "risky", "dur_ms": 3.1, "error": "ValueError"}
141
+ ```
142
+
143
+ A failure inside the recording itself is logged and ignored: measuring something
144
+ must not break it.
145
+
146
+ ## Threads and tasks
147
+
148
+ The nesting stack is per thread and per asyncio task, so concurrent work does not
149
+ nest inside unrelated spans:
150
+
151
+ ```python
152
+ async def work(name):
153
+ async with et.span(name): # "a" and "b", never "a/b"
154
+ await asyncio.sleep(1)
155
+
156
+ await asyncio.gather(work("a"), work("b"))
157
+ ```
158
+
159
+ ## Printing a span as it runs
160
+
161
+ Pass `print_fn` and the span announces itself, indented one tab per level:
162
+
163
+ ```python
164
+ with et.span("step", print_fn=print):
165
+ with et.span("forward"):
166
+ with et.span("attention"):
167
+ ...
168
+ ```
169
+
170
+ ```
171
+ -> step 16:41:38
172
+ -> forward 16:41:38
173
+ -> attention 16:41:38
174
+ <- attention 16:41:38 3.074ms
175
+ <- forward 16:41:38 5.481ms
176
+ <- step 16:41:38 9.641ms
177
+ ```
178
+
179
+ Children inherit the handler, so one argument on the outermost span prints the
180
+ whole tree. A child that passes its own `print_fn` takes over its subtree; pass
181
+ `print_fn=lambda line: None` to silence one. A failed span ends with `!ValueError`.
182
+
183
+ Indentation is relative to the span that started printing, not to absolute
184
+ nesting depth, so turning printing on deep inside a call stack still gives you a
185
+ tree rooted at the left margin. A new thread begins its own tree, because the
186
+ nesting stack does not cross threads. Concurrent asyncio tasks share one handler
187
+ and interleave their lines; each is still indented by its own depth, and the
188
+ `->` and `<-` markers pair them up.
189
+
190
+ Any callable taking one string works — `print`, `logger.info`, a list's `append`
191
+ in tests. It is called on the thread that ran the span, and an exception in it is
192
+ logged and swallowed.
193
+
194
+ Set it for the whole run instead of per span with `span_print_fn`:
195
+
196
+ ```python
197
+ et.init(project="demo", span_print_fn=logger.info)
198
+ ```
199
+
200
+ ## Plugins
201
+
202
+ A plugin measures a resource across a span. It is any object with
203
+ `start(span)` and `end(span) -> dict`; a plain `fn(span) -> dict` is an
204
+ end-only plugin. Whatever `end` returns is merged into the span's metrics under
205
+ the span's own path:
206
+
207
+ ```python
208
+ from expr_tracker.plugins import CpuTime, GpuStats, TorchMemory
209
+
210
+ with et.span("forward", plugins=[CpuTime(), TorchMemory()]):
211
+ loss = model(batch)
212
+ ```
213
+
214
+ ```python
215
+ et.history(1)[0]
216
+ # {"forward/duration_ms": 41.2, "forward/count": 1,
217
+ # "forward/cpu_percent": 101.2, "forward/cpu_time_ms": 41.4,
218
+ # "forward/gpu_mem_peak_mb": 456.1, "forward/gpu_mem_delta_mb": 8.1, ...}
219
+ ```
220
+
221
+ Like `print_fn`, plugins are inherited by children, can be overridden per span,
222
+ and can be set run-wide with `span_plugins`. They are opt-in because they are not
223
+ free. A plugin that raises is logged and skipped; it never breaks the span or the
224
+ code being measured, and a plugin that fails on every span is logged only once.
225
+ Plugin metrics also reach `spans.jsonl` and show up in the `et trace` viewer
226
+ under a span's *Arguments*.
227
+
228
+ `duration_ms` and `count` belong to the span. A plugin returning either has that
229
+ key dropped with a warning, so nothing can quietly redefine what a span measured.
230
+
231
+ ### Built-ins
232
+
233
+ | plugin | metrics | needs |
234
+ | --- | --- | --- |
235
+ | `CpuTime()` | `cpu_time_ms`, `cpu_percent` | nothing |
236
+ | `TorchMemory(device=None)` | `gpu_mem_peak_mb`, `gpu_mem_delta_mb` | `torch` + CUDA |
237
+ | `GpuStats(index=0, interval=0.1)` | `gpu_percent`, `gpu_mem_used_mb` | `pip install expr-tracker[gpu]` |
238
+
239
+ `CpuTime` divides process CPU time by wall time, so `cpu_percent` above 100 means
240
+ the span used more than one core. It counts every thread in the process, so
241
+ unrelated concurrent work inflates it, and it is noise on spans under a
242
+ millisecond.
243
+
244
+ `TorchMemory` reports what your own allocator held, which is the number that
245
+ predicts an OOM. `gpu_mem_peak_mb` is absolute, so memory a tensor was already
246
+ holding when the span opened counts towards it; `gpu_mem_delta_mb` is what the
247
+ span kept. `torch.cuda.max_memory_allocated` is a single global high-water mark,
248
+ so a child span resetting it would erase its parent's history — the plugin keeps
249
+ its own stack and hands each parent back the peak it had reached before the child
250
+ opened.
251
+
252
+ `GpuStats` reads the whole device, including other processes. A background thread
253
+ samples NVML every `interval`, and a span reports the mean utilisation and the
254
+ peak memory over its own window, so the span itself pays almost nothing. Spans
255
+ shorter than `interval` contain no sample and fall back to one live reading,
256
+ which for utilisation is NVML's own rolling average rather than a measurement of
257
+ that span. Samplers are shared across plugins with the same device and interval.
258
+
259
+ ### CUDA is asynchronous
260
+
261
+ A span measures the wall time of the Python block, and CUDA kernels are queued,
262
+ not awaited. A span around a launch can close in microseconds while the GPU is
263
+ still busy, and the wait then lands on whatever later line synchronises. If you
264
+ want a span to mean GPU time, synchronise inside it:
265
+
266
+ ```python
267
+ with et.span("forward", plugins=[TorchMemory()]):
268
+ out = model(batch)
269
+ torch.cuda.synchronize()
270
+ ```
271
+
272
+ `gpu_mem_peak_mb` is unaffected: allocator bookkeeping is recorded at launch.
273
+
274
+ ## Cost
275
+
276
+ | | per span |
277
+ | --- | --- |
278
+ | default | ~15 µs |
279
+ | `spans=False` | ~6 µs |
280
+ | no active run | ~3 µs |
281
+ | `print_fn` | +6 µs |
282
+ | `CpuTime()` | +8 µs |
283
+ | `GpuStats()` | +4 µs, or +36 µs on a span shorter than `interval` |
284
+ | `TorchMemory()` | +62 µs |
285
+
286
+ For comparison, `et.log()` is ~24 µs. Twenty spans on a 100 ms step is 0.3% of
287
+ the step. If your step is closer to a millisecond, set `spans=False` and keep the
288
+ metrics, or time fewer regions. Put `TorchMemory` on the few spans whose memory
289
+ you actually care about rather than run-wide.
@@ -57,6 +57,39 @@ from expr_tracker.history import list_streams
57
57
  list_streams("runs/llm/sft-1") # [None, "data"]
58
58
  ```
59
59
 
60
+ ```bash
61
+ et history runs/llm/sft-1 --stream data
62
+ et trace runs/llm/sft-1 # every stream, one lane each
63
+ ```
64
+
65
+ ## A worked example
66
+
67
+ `examples/multiprocess_pipeline.py` runs four data producers and four trainers as
68
+ eight processes sharing one run, with a queue that lets a producer run at most
69
+ `--staleness` batches ahead of the trainers. Each worker writes its own stream,
70
+ so the exported trace gives each one a lane and the blocking spans show which
71
+ side is the bottleneck:
72
+
73
+ ```bash
74
+ # producers faster than trainers: they stall on a full queue
75
+ uv run python examples/multiprocess_pipeline.py --produce-ms 10 --train-ms 40
76
+
77
+ # trainers faster than producers: they starve waiting for batches
78
+ uv run python examples/multiprocess_pipeline.py --produce-ms 40 --train-ms 10
79
+ ```
80
+
81
+ ```
82
+ producers 0.38s in produce
83
+ read 0.15s 37.9%
84
+ enqueue 0.13s 35.0% <- backpressure
85
+ decode 0.09s 24.5%
86
+
87
+ trainers 1.05s in step
88
+ backward 0.59s 55.9%
89
+ forward 0.40s 38.1%
90
+ wait_for_batch 0.05s 4.9% <- starvation
91
+ ```
92
+
60
93
  ## Alerts
61
94
 
62
95
  Each process alerts on what it can see, which is its own stream. That is usually
@@ -42,6 +42,7 @@ uv add "expr_tracker[all]" # + wandb, trackio, lark, pandas, polars
42
42
  ## Next
43
43
 
44
44
  - [Getting started](getting-started.md) — a complete run, end to end.
45
+ - [Examples](examples.md) — six runnable programs, all offline.
45
46
  - [Logging metrics](guide/logging.md) — commit semantics and the step model.
46
47
  - [Alerts](guide/alerts.md) — rules, channels and delivery policy.
47
48
  - [Design](design.md) — the data model and the invariants behind it.
@@ -26,6 +26,8 @@ et.init(
26
26
  stream: str | None = None,
27
27
  alert=None,
28
28
  alert_rules: Sequence = (),
29
+ alert_on_rank: int | None = 0,
30
+ backend_on_rank: int | None = 0,
29
31
  **history_options,
30
32
  ) -> Run
31
33
  ```
@@ -34,6 +36,14 @@ Starts a run and publishes it as the process-wide current run. `name` defaults t
34
36
  timestamp. `dir` defaults to `./tracker/jsonl`. Extra keyword arguments are
35
37
  [history options](configuration.md#history-options).
36
38
 
39
+ `resume` is forwarded to wandb and trackio. The local history always continues an
40
+ existing run directory, so re-running with the same project and name picks up the
41
+ step cursor whatever `resume` says; use a new name to start clean.
42
+
43
+ `alert_on_rank` and `backend_on_rank` pick which rank alerts and which opens the
44
+ remote backend run; `None` means every rank. See
45
+ [Distributed runs](../guide/distributed.md).
46
+
37
47
  Calling `init()` twice without `finish()` raises.
38
48
 
39
49
  ### `finish`
@@ -156,14 +166,35 @@ Registers a custom channel type.
156
166
  ## Spans
157
167
 
158
168
  ```python
159
- with et.span(name, **attributes) as span: ... # also async, also a decorator
160
- span = et.start_span(name, **attributes) # ends with span.end()
169
+ with et.span(name, print_fn=None, plugins=(), **attributes) as span: ...
170
+ span = et.start_span(name, print_fn=None, plugins=(), **attributes)
161
171
  span.set(**attributes)
162
172
  span.duration_ms
173
+ span.metrics # what the plugins measured
163
174
  ```
164
175
 
165
- A closed span adds `<path>/duration_ms` and `<path>/count` to the open row and
166
- appends the full record to `spans.jsonl`. See [Spans](../guide/spans.md).
176
+ `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`.
179
+
180
+ `print_fn(line)` announces the span's start and end, indented one tab per level.
181
+ `plugins` measure a resource across the span; both are inherited by child spans
182
+ and default to the run's `span_print_fn` and `span_plugins`.
183
+
184
+ ### Plugins
185
+
186
+ ```python
187
+ from expr_tracker.plugins import CpuTime, GpuStats, TorchMemory
188
+ ```
189
+
190
+ | plugin | metrics |
191
+ | --- | --- |
192
+ | `CpuTime()` | `cpu_time_ms`, `cpu_percent` |
193
+ | `TorchMemory(device=None)` | `gpu_mem_peak_mb`, `gpu_mem_delta_mb` |
194
+ | `GpuStats(index=0, interval=0.1)` | `gpu_percent`, `gpu_mem_used_mb` |
195
+
196
+ Any object with `start(span)` and `end(span) -> dict` is a plugin, as is a plain
197
+ `fn(span) -> dict`. See [Spans](../guide/spans.md).
167
198
 
168
199
  ### Trace export
169
200
 
@@ -15,6 +15,8 @@ list of valid options rather than being silently ignored.
15
15
  | `rank_aware` | `True` | non-zero ranks write their own shard |
16
16
  | `stream` | `None` | an independent producer with its own file and step cursor |
17
17
  | `spans` | `True` | write the span tree to `spans.jsonl` beside the metrics |
18
+ | `span_print_fn` | `None` | default `print_fn` for every span in the run |
19
+ | `span_plugins` | `()` | default plugins for every span in the run |
18
20
  | `print_to_screen` | `False` | print every committed row |
19
21
  | `print_handle` | `print` | where those lines go |
20
22
  | `buffer_size` | 50 | flush after this many buffered rows |