trace-lanefix 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roman Ralovets
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: trace-lanefix
3
+ Version: 0.1.0
4
+ Summary: Fix overlapping Chrome/Kineto trace slices so Perfetto can render them.
5
+ Author: Roman Ralovets
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/ralovets/trace-lanefix
8
+ Project-URL: Issues, https://github.com/ralovets/trace-lanefix/issues
9
+ Keywords: chrome-trace,kineto,perfetto,profiling,pytorch
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Debuggers
18
+ Classifier: Topic :: System :: Benchmark
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # trace-lanefix
27
+
28
+ `trace-lanefix` rewrites Chrome/Kineto trace JSON so Perfetto can render slices
29
+ that would otherwise be dropped because of crossing overlaps on the same
30
+ `(pid, tid)` track.
31
+
32
+ It keeps properly nested slices on the same track, snaps tiny timestamp-boundary
33
+ crossings, and moves genuine overlaps to synthetic lanes with collision-free
34
+ thread ids.
35
+
36
+ ## Before / After
37
+
38
+ Before:
39
+
40
+ <img src="https://raw.githubusercontent.com/ralovets/trace-lanefix/main/assets/profile-before.png" alt="Profile before lane fix" width="100%">
41
+
42
+ After:
43
+
44
+ <img src="https://raw.githubusercontent.com/ralovets/trace-lanefix/main/assets/profile-after.png" alt="Profile after lane fix" width="100%">
45
+
46
+ In Perfetto's trace overview, the rewritten trace reports `TRACE ERRORS=0`
47
+ instead of `TRACE ERRORS=6`, while `IMPORT ERRORS` and `DATA LOSSES` remain
48
+ at `0`. In this example, the JSON trace size changed from `452.76 KiB` before
49
+ rewriting (`463,629` bytes) to `653.96 KiB` after rewriting (`669,651`
50
+ bytes) because the output is pretty-printed for readability.
51
+
52
+ ## CLI
53
+
54
+ ```bash
55
+ trace-lanefix input.json output.fixed.json
56
+ ```
57
+
58
+ Tiny crossings below `5.0us` are snapped by default. To use a different snap
59
+ threshold:
60
+
61
+ ```bash
62
+ trace-lanefix --snap-threshold-us 2.5 input.json output.fixed.json
63
+ ```
64
+
65
+ Example output:
66
+
67
+ This run shows the two repair paths: tiny sub-5.0us timestamp crossings are
68
+ snapped in place, while one genuine overlap is moved to a synthetic lane. Flow
69
+ endpoints are then rewritten to follow the adjusted slice starts and lanes.
70
+
71
+ ```text
72
+ trace-lanefix trace.json trace_fixed.json
73
+ processing 3 track(s)
74
+ pid=7334 tid=7334: 2 lane(s), 1 slice(s) moved, 11 snapped
75
+ pid=0 tid=7: 1 lane(s), 0 slice(s) moved, 5 snapped
76
+ snapped 16 sub-5.0us crossing(s)
77
+ updated 15 slice(s): total |start shift|=6.195us, total |end shift|=5.240us, total |boundary shift|=11.436us
78
+ kernel timing summary (5 run(s) across 3 kernel name(s)):
79
+ nvjet_sm120_tst_mma_256x112x64_2_64x56x64_tmaAB_bz_TNNN: runs=2, total delayed=2.848us, total trimmed=0.000us, total boundary shift=2.848us, moved=0, snapped=2
80
+ nvjet_sm120_tst_mma_96x256x64_2_48x64x64_tmaAB_bz_TNNN: runs=2, total delayed=480ns, total trimmed=0.000us, total boundary shift=480ns, moved=0, snapped=2
81
+ nvjet_sm120_tst_mma_128x256x64_2_64x64x64_tmaAB_bz_TNNN: runs=1, total delayed=384ns, total trimmed=0.000us, total boundary shift=384ns, moved=0, snapped=1
82
+ moved slices (1):
83
+ ProfilerStep#5 pid=7334: moved to lane 1 (tid 7334 -> 7334001)
84
+ snapped slices (9):
85
+ layer0_forward pid=7334 tid=7334: start delayed by 2.483us
86
+ torch/autograd/profiler.py(843): __exit__ pid=7334 tid=7334: end trimmed by 2.855us
87
+ torch/_ops.py(1079): __call__ pid=7334 tid=7334: end trimmed by 1.222us
88
+ <built-in method of pybind11_builtins.pybind11_detail_function_record_v1_system_libstdcpp_gxx_abi_1xxx_use_cxx11_abi_1 object at 0x719b5a7004b0> pid=7334 tid=7334: end trimmed by 1.162us
89
+ contextlib.py(141): __exit__ pid=7334 tid=7334: end trimmed by 0.2ns
90
+ profiling.py(19): trace pid=7334 tid=7334: end trimmed by 0.2ns
91
+ torch/profiler/profiler.py(912): _transit_action pid=7334 tid=7334: end trimmed by 0.2ns
92
+ torch/cuda/__init__.py(1161): synchronize pid=7334 tid=7334: end trimmed by 0.2ns
93
+ torch/cuda/__init__.py(609): __exit__ pid=7334 tid=7334: end trimmed by 0.2ns
94
+ rewrote 5 flow endpoint(s) onto rewritten slice starts/lanes
95
+ wrote trace_fixed.json: 988 slices, 1 new lanes
96
+ ```
97
+
98
+ ## Python API
99
+
100
+ ```python
101
+ from trace_lanefix import fix_overlaps
102
+
103
+ fix_overlaps("input.json", "output.fixed.json")
104
+ fix_overlaps("input.json", "output.fixed.json", snap_threshold_us=2.5)
105
+ ```
@@ -0,0 +1,80 @@
1
+ # trace-lanefix
2
+
3
+ `trace-lanefix` rewrites Chrome/Kineto trace JSON so Perfetto can render slices
4
+ that would otherwise be dropped because of crossing overlaps on the same
5
+ `(pid, tid)` track.
6
+
7
+ It keeps properly nested slices on the same track, snaps tiny timestamp-boundary
8
+ crossings, and moves genuine overlaps to synthetic lanes with collision-free
9
+ thread ids.
10
+
11
+ ## Before / After
12
+
13
+ Before:
14
+
15
+ <img src="https://raw.githubusercontent.com/ralovets/trace-lanefix/main/assets/profile-before.png" alt="Profile before lane fix" width="100%">
16
+
17
+ After:
18
+
19
+ <img src="https://raw.githubusercontent.com/ralovets/trace-lanefix/main/assets/profile-after.png" alt="Profile after lane fix" width="100%">
20
+
21
+ In Perfetto's trace overview, the rewritten trace reports `TRACE ERRORS=0`
22
+ instead of `TRACE ERRORS=6`, while `IMPORT ERRORS` and `DATA LOSSES` remain
23
+ at `0`. In this example, the JSON trace size changed from `452.76 KiB` before
24
+ rewriting (`463,629` bytes) to `653.96 KiB` after rewriting (`669,651`
25
+ bytes) because the output is pretty-printed for readability.
26
+
27
+ ## CLI
28
+
29
+ ```bash
30
+ trace-lanefix input.json output.fixed.json
31
+ ```
32
+
33
+ Tiny crossings below `5.0us` are snapped by default. To use a different snap
34
+ threshold:
35
+
36
+ ```bash
37
+ trace-lanefix --snap-threshold-us 2.5 input.json output.fixed.json
38
+ ```
39
+
40
+ Example output:
41
+
42
+ This run shows the two repair paths: tiny sub-5.0us timestamp crossings are
43
+ snapped in place, while one genuine overlap is moved to a synthetic lane. Flow
44
+ endpoints are then rewritten to follow the adjusted slice starts and lanes.
45
+
46
+ ```text
47
+ trace-lanefix trace.json trace_fixed.json
48
+ processing 3 track(s)
49
+ pid=7334 tid=7334: 2 lane(s), 1 slice(s) moved, 11 snapped
50
+ pid=0 tid=7: 1 lane(s), 0 slice(s) moved, 5 snapped
51
+ snapped 16 sub-5.0us crossing(s)
52
+ updated 15 slice(s): total |start shift|=6.195us, total |end shift|=5.240us, total |boundary shift|=11.436us
53
+ kernel timing summary (5 run(s) across 3 kernel name(s)):
54
+ nvjet_sm120_tst_mma_256x112x64_2_64x56x64_tmaAB_bz_TNNN: runs=2, total delayed=2.848us, total trimmed=0.000us, total boundary shift=2.848us, moved=0, snapped=2
55
+ nvjet_sm120_tst_mma_96x256x64_2_48x64x64_tmaAB_bz_TNNN: runs=2, total delayed=480ns, total trimmed=0.000us, total boundary shift=480ns, moved=0, snapped=2
56
+ nvjet_sm120_tst_mma_128x256x64_2_64x64x64_tmaAB_bz_TNNN: runs=1, total delayed=384ns, total trimmed=0.000us, total boundary shift=384ns, moved=0, snapped=1
57
+ moved slices (1):
58
+ ProfilerStep#5 pid=7334: moved to lane 1 (tid 7334 -> 7334001)
59
+ snapped slices (9):
60
+ layer0_forward pid=7334 tid=7334: start delayed by 2.483us
61
+ torch/autograd/profiler.py(843): __exit__ pid=7334 tid=7334: end trimmed by 2.855us
62
+ torch/_ops.py(1079): __call__ pid=7334 tid=7334: end trimmed by 1.222us
63
+ <built-in method of pybind11_builtins.pybind11_detail_function_record_v1_system_libstdcpp_gxx_abi_1xxx_use_cxx11_abi_1 object at 0x719b5a7004b0> pid=7334 tid=7334: end trimmed by 1.162us
64
+ contextlib.py(141): __exit__ pid=7334 tid=7334: end trimmed by 0.2ns
65
+ profiling.py(19): trace pid=7334 tid=7334: end trimmed by 0.2ns
66
+ torch/profiler/profiler.py(912): _transit_action pid=7334 tid=7334: end trimmed by 0.2ns
67
+ torch/cuda/__init__.py(1161): synchronize pid=7334 tid=7334: end trimmed by 0.2ns
68
+ torch/cuda/__init__.py(609): __exit__ pid=7334 tid=7334: end trimmed by 0.2ns
69
+ rewrote 5 flow endpoint(s) onto rewritten slice starts/lanes
70
+ wrote trace_fixed.json: 988 slices, 1 new lanes
71
+ ```
72
+
73
+ ## Python API
74
+
75
+ ```python
76
+ from trace_lanefix import fix_overlaps
77
+
78
+ fix_overlaps("input.json", "output.fixed.json")
79
+ fix_overlaps("input.json", "output.fixed.json", snap_threshold_us=2.5)
80
+ ```
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "trace-lanefix"
7
+ version = "0.1.0"
8
+ description = "Fix overlapping Chrome/Kineto trace slices so Perfetto can render them."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [
13
+ {name = "Roman Ralovets"},
14
+ ]
15
+ keywords = ["chrome-trace", "kineto", "perfetto", "profiling", "pytorch"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Software Development :: Debuggers",
25
+ "Topic :: System :: Benchmark",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=8"]
30
+
31
+ [project.urls]
32
+ Repository = "https://github.com/ralovets/trace-lanefix"
33
+ Issues = "https://github.com/ralovets/trace-lanefix/issues"
34
+
35
+ [project.scripts]
36
+ trace-lanefix = "trace_lanefix.cli:main"
37
+
38
+ [tool.setuptools.packages.find]
39
+ include = ["trace_lanefix*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,213 @@
1
+ import json
2
+ from collections import defaultdict
3
+
4
+ import pytest
5
+
6
+ from trace_lanefix import fix_overlaps
7
+
8
+
9
+ def _run_fix(trace, tmp_path, **kwargs):
10
+ in_path = tmp_path / "in.json"
11
+ out_path = tmp_path / "out.json"
12
+ in_path.write_text(json.dumps(trace))
13
+ fix_overlaps(str(in_path), str(out_path), **kwargs)
14
+ return json.loads(out_path.read_text())["traceEvents"]
15
+
16
+
17
+ def _assert_no_crossings(events):
18
+ slices = [e for e in events if e.get("ph") == "X" and "dur" in e]
19
+ by_track = defaultdict(list)
20
+ for e in slices:
21
+ by_track[(e.get("pid"), e.get("tid"))].append(e)
22
+
23
+ for group in by_track.values():
24
+ group.sort(key=lambda e: (e["ts"], -e["dur"]))
25
+ stack = []
26
+ for e in group:
27
+ start = e["ts"]
28
+ end = start + e["dur"]
29
+ while stack and stack[-1][0] <= start:
30
+ stack.pop()
31
+ if stack:
32
+ top_end, top_start = stack[-1]
33
+ assert top_start <= start and end <= top_end
34
+ stack.append((end, start))
35
+
36
+
37
+ def test_start_snap_rechecks_outer_parent(tmp_path):
38
+ trace = {
39
+ "traceEvents": [
40
+ {"ph": "X", "cat": "python_function", "name": "outer", "pid": 1, "tid": 1,
41
+ "ts": 0.0, "dur": 3.405},
42
+ {"ph": "X", "cat": "python_function", "name": "inner", "pid": 1, "tid": 1,
43
+ "ts": 0.170, "dur": 3.205},
44
+ {"ph": "X", "cat": "user_annotation", "name": "layer0_forward", "pid": 1, "tid": 1,
45
+ "ts": 1.122, "dur": 10.0},
46
+ ]
47
+ }
48
+
49
+ events = _run_fix(trace, tmp_path)
50
+ _assert_no_crossings(events)
51
+
52
+ layer = next(e for e in events if e.get("name") == "layer0_forward")
53
+ assert layer["tid"] == 1
54
+ assert layer["ts"] == pytest.approx(3.405)
55
+ assert layer["dur"] == pytest.approx(7.717)
56
+
57
+
58
+ def test_flow_endpoint_tracks_start_snap(tmp_path):
59
+ trace = {
60
+ "traceEvents": [
61
+ {"ph": "X", "cat": "cuda_runtime", "name": "cudaLaunchKernel", "pid": 11, "tid": 11,
62
+ "ts": 10.0, "dur": 2.0},
63
+ {"ph": "s", "id": 1, "pid": 11, "tid": 11, "ts": 10.0, "cat": "ac2g", "name": "ac2g"},
64
+ {"ph": "X", "cat": "kernel", "name": "kernel_a", "pid": 0, "tid": 7,
65
+ "ts": 100.0, "dur": 50.0},
66
+ {"ph": "X", "cat": "kernel", "name": "kernel_b", "pid": 0, "tid": 7,
67
+ "ts": 149.7, "dur": 10.0},
68
+ {"ph": "f", "id": 1, "pid": 0, "tid": 7, "ts": 149.7, "cat": "ac2g", "name": "ac2g", "bp": "e"},
69
+ ]
70
+ }
71
+
72
+ events = _run_fix(trace, tmp_path)
73
+ _assert_no_crossings(events)
74
+
75
+ kernel_b = next(e for e in events if e.get("name") == "kernel_b")
76
+ flow_f = next(e for e in events if e.get("ph") == "f" and e.get("id") == 1)
77
+
78
+ assert kernel_b["tid"] == 7
79
+ assert kernel_b["ts"] == pytest.approx(150.0)
80
+ assert flow_f["tid"] == 7
81
+ assert flow_f["ts"] == pytest.approx(150.0)
82
+
83
+
84
+ def test_snap_threshold_can_force_synthetic_lane(tmp_path):
85
+ trace = {
86
+ "traceEvents": [
87
+ {"ph": "X", "cat": "kernel", "name": "kernel_a", "pid": 0, "tid": 7,
88
+ "ts": 100.0, "dur": 50.0},
89
+ {"ph": "X", "cat": "kernel", "name": "kernel_b", "pid": 0, "tid": 7,
90
+ "ts": 149.7, "dur": 10.0},
91
+ ]
92
+ }
93
+
94
+ events = _run_fix(trace, tmp_path, snap_threshold_us=0.1)
95
+ _assert_no_crossings(events)
96
+
97
+ kernel_b = next(e for e in events if e.get("name") == "kernel_b")
98
+ assert kernel_b["tid"] == 7001
99
+ assert kernel_b["ts"] == pytest.approx(149.7)
100
+
101
+
102
+ def test_child_before_start_snapped_parent_is_rechecked(tmp_path):
103
+ trace = {
104
+ "traceEvents": [
105
+ {"ph": "X", "cat": "cpu", "name": "previous", "pid": 1, "tid": 1,
106
+ "ts": 0.0, "dur": 10.0},
107
+ {"ph": "X", "cat": "cpu", "name": "parent", "pid": 1, "tid": 1,
108
+ "ts": 9.9, "dur": 10.0},
109
+ {"ph": "X", "cat": "cpu", "name": "child", "pid": 1, "tid": 1,
110
+ "ts": 9.95, "dur": 1.0},
111
+ ]
112
+ }
113
+
114
+ events = _run_fix(trace, tmp_path)
115
+ _assert_no_crossings(events)
116
+
117
+ parent = next(e for e in events if e.get("name") == "parent")
118
+ child = next(e for e in events if e.get("name") == "child")
119
+ assert parent["tid"] == 1
120
+ assert parent["ts"] == pytest.approx(10.0)
121
+ assert child["tid"] == 1
122
+ assert child["ts"] == pytest.approx(10.0)
123
+ assert child["dur"] == pytest.approx(0.95)
124
+
125
+
126
+ def test_synthetic_lane_tid_avoids_real_track_collision(tmp_path):
127
+ trace = {
128
+ "traceEvents": [
129
+ {"ph": "X", "cat": "cpu", "name": "real_1001", "pid": 1, "tid": 1001,
130
+ "ts": 100.0, "dur": 1.0},
131
+ {"ph": "X", "cat": "cpu", "name": "a", "pid": 1, "tid": 1,
132
+ "ts": 0.0, "dur": 10.0},
133
+ {"ph": "X", "cat": "cpu", "name": "b", "pid": 1, "tid": 1,
134
+ "ts": 5.0, "dur": 10.0},
135
+ ]
136
+ }
137
+
138
+ events = _run_fix(trace, tmp_path)
139
+ _assert_no_crossings(events)
140
+
141
+ moved = next(e for e in events if e.get("name") == "b")
142
+ real = next(e for e in events if e.get("name") == "real_1001")
143
+ lane_name = next(
144
+ e for e in events
145
+ if e.get("name") == "thread_name" and e.get("args", {}).get("name") == "tid 1 lane 1"
146
+ )
147
+ assert real["tid"] == 1001
148
+ assert moved["tid"] == "1 lane 1"
149
+ assert lane_name["tid"] == "1 lane 1"
150
+
151
+
152
+ def test_string_tid_can_move_to_synthetic_lane(tmp_path):
153
+ trace = {
154
+ "traceEvents": [
155
+ {"ph": "X", "cat": "cpu", "name": "a", "pid": 1, "tid": "worker",
156
+ "ts": 0.0, "dur": 10.0},
157
+ {"ph": "X", "cat": "cpu", "name": "b", "pid": 1, "tid": "worker",
158
+ "ts": 5.0, "dur": 10.0},
159
+ ]
160
+ }
161
+
162
+ events = _run_fix(trace, tmp_path)
163
+ _assert_no_crossings(events)
164
+
165
+ moved = next(e for e in events if e.get("name") == "b")
166
+ assert moved["tid"] == "worker lane 1"
167
+
168
+
169
+ def test_output_json_is_pretty_printed(tmp_path):
170
+ trace = {
171
+ "schemaVersion": 1,
172
+ "traceEvents": [
173
+ {"ph": "X", "cat": "cpu", "name": "a", "pid": 1, "tid": 1,
174
+ "ts": 0.0, "dur": 1.0},
175
+ ],
176
+ }
177
+ in_path = tmp_path / "in.json"
178
+ out_path = tmp_path / "out.json"
179
+ in_path.write_text(json.dumps(trace))
180
+
181
+ fix_overlaps(str(in_path), str(out_path))
182
+
183
+ text = out_path.read_text()
184
+ assert text.endswith("\n")
185
+ assert "\n \"schemaVersion\": 1,\n" in text
186
+ assert "\n {\n" in text
187
+
188
+
189
+ def test_reports_grouped_kernel_summary_sorted_by_total_shift(tmp_path, capsys):
190
+ trace = {
191
+ "traceEvents": [
192
+ {"ph": "X", "cat": "kernel", "name": "kernel_a", "pid": 0, "tid": 7,
193
+ "ts": 100.0, "dur": 50.0},
194
+ {"ph": "X", "cat": "kernel", "name": "kernel_c", "pid": 0, "tid": 7,
195
+ "ts": 120.0, "dur": 40.0},
196
+ {"ph": "X", "cat": "kernel", "name": "kernel_b", "pid": 0, "tid": 7,
197
+ "ts": 149.7, "dur": 10.0},
198
+ {"ph": "X", "cat": "kernel", "name": "kernel_b", "pid": 0, "tid": 7,
199
+ "ts": 159.6, "dur": 10.0},
200
+ ]
201
+ }
202
+
203
+ events = _run_fix(trace, tmp_path)
204
+ _assert_no_crossings(events)
205
+
206
+ out = capsys.readouterr().out
207
+ assert "updated 3 slice(s)" in out
208
+ assert "kernel timing summary (3 run(s) across 2 kernel name(s)):" in out
209
+ assert " kernel_b: runs=2, total delayed=400ns, total trimmed=0.000us, total boundary shift=400ns, moved=0, snapped=2" in out
210
+ assert " kernel_c: runs=1, total delayed=0.000us, total trimmed=0.000us, total boundary shift=0.000us, moved=1, snapped=0" in out
211
+ assert "moved slices" not in out
212
+ assert "snapped slices" not in out
213
+ assert out.index(" kernel_b:") < out.index(" kernel_c:")
@@ -0,0 +1,5 @@
1
+ """Utilities for making Chrome/Kineto traces render cleanly in Perfetto."""
2
+
3
+ from .core import SNAP_THRESHOLD_US, fix_overlaps
4
+
5
+ __all__ = ["SNAP_THRESHOLD_US", "fix_overlaps"]
@@ -0,0 +1,51 @@
1
+ """Command-line interface for trace-lanefix."""
2
+
3
+ import argparse
4
+
5
+ from .core import SNAP_THRESHOLD_US, fix_overlaps
6
+
7
+
8
+ def _non_negative_float(value: str) -> float:
9
+ parsed = float(value)
10
+ if parsed < 0:
11
+ raise argparse.ArgumentTypeError("must be non-negative")
12
+ return parsed
13
+
14
+
15
+ def build_parser() -> argparse.ArgumentParser:
16
+ parser = argparse.ArgumentParser(
17
+ prog="trace-lanefix",
18
+ description="Rewrite a Chrome/Kineto trace so Perfetto can render overlapping slices.",
19
+ )
20
+ parser.add_argument(
21
+ "input",
22
+ nargs="?",
23
+ default="trace.json",
24
+ help="input Chrome trace JSON path (default: trace.json)",
25
+ )
26
+ parser.add_argument(
27
+ "output",
28
+ nargs="?",
29
+ default="trace.fixed.json",
30
+ help="output fixed trace JSON path (default: trace.fixed.json)",
31
+ )
32
+ parser.add_argument(
33
+ "--snap-threshold-us",
34
+ type=_non_negative_float,
35
+ default=SNAP_THRESHOLD_US,
36
+ help=(
37
+ "maximum timestamp crossing, in microseconds, to snap in place "
38
+ f"instead of moving to a synthetic lane (default: {SNAP_THRESHOLD_US})"
39
+ ),
40
+ )
41
+ return parser
42
+
43
+
44
+ def main(argv: list[str] | None = None) -> int:
45
+ args = build_parser().parse_args(argv)
46
+ fix_overlaps(args.input, args.output, snap_threshold_us=args.snap_threshold_us)
47
+ return 0
48
+
49
+
50
+ if __name__ == "__main__":
51
+ raise SystemExit(main())
@@ -0,0 +1,377 @@
1
+ """Rewrite a Chrome/Kineto trace so Perfetto can render every slice.
2
+
3
+ Perfetto's slice model requires strict stack nesting per (pid, tid): two
4
+ slices on the same track must be disjoint or one must fully contain the
5
+ other. Slices that *cross* a boundary get dropped
6
+ (SLICE_DROP_OVERLAPPING_COMPLETE_EVENT).
7
+
8
+ Strategy — resolve crossings with the minimum visual disturbance:
9
+
10
+ 1. Nesting-aware placement. Each lane keeps a live stack of open frames.
11
+ An event joins a lane if, after popping closed frames, the lane is
12
+ empty or the current top strictly contains the event. Nested children
13
+ stay on their parent's lane.
14
+
15
+ 2. Snap tiny crossings instead of spawning a lane. Two directions are
16
+ considered for every crossing, and the smaller perturbation wins:
17
+ - end-snap: clamp the child's end down to its parent's end (nests).
18
+ - start-snap: push the child's start forward to its parent's end
19
+ (turns it into a disjoint sibling).
20
+ The smallest shift is the one that actually represents CUPTI/Kineto
21
+ timestamp noise; end-snap fits float-epsilon parent-end overruns,
22
+ start-snap fits the typical GPU-stream case where two kernels appear
23
+ to overlap by ~100ns at the boundary even though they're back-to-back
24
+ on the stream. Only shifts below the configured snap threshold are applied, so
25
+ genuine concurrent slices (ProfilerStep#N, kernels on separate streams
26
+ sharing a track) still go to a secondary lane.
27
+
28
+ 3. No category gate. Every track runs through the same pass. GPU streams
29
+ behave identically to the old algorithm (no nesting ⇒ same lane
30
+ decisions) and CPU threads no longer explode.
31
+
32
+ 4. Collision-free lane ids. Synthetic tids keep the historical numeric
33
+ `tid * 1000 + lane` form when that id is available, and fall back to a
34
+ string id when it would collide with a real track.
35
+ """
36
+
37
+ import json
38
+ from collections import defaultdict
39
+
40
+ SNAP_THRESHOLD_US = 5.0 # absolute shift below which we clamp instead of laning.
41
+ # Calibrated against CUPTI boundary noise between consecutive same-stream kernels
42
+ # (observed max ~2.5us on nvjet tensor-core kernels). Kernels on one CUDA stream
43
+ # cannot physically overlap, so any sub-threshold overlap is timing noise.
44
+
45
+
46
+ def _format_us(value: float) -> str:
47
+ if 0 < abs(value) < 1.0:
48
+ return f"{value * 1000.0:.1f}".rstrip("0").rstrip(".") + "ns"
49
+ return f"{value:.3f}us"
50
+
51
+
52
+ def _describe_change(change: dict) -> str:
53
+ start_delta = change["new_start"] - change["orig_start"]
54
+ end_delta = change["new_end"] - change["orig_end"]
55
+ parts = []
56
+
57
+ if change["orig_tid"] != change["new_tid"]:
58
+ parts.append(f"moved to lane {change['lane_idx']} (tid {change['orig_tid']} -> {change['new_tid']})")
59
+
60
+ if start_delta == end_delta and start_delta != 0:
61
+ parts.append(f"shifted {'later' if start_delta > 0 else 'earlier'} by {_format_us(abs(start_delta))}")
62
+ else:
63
+ if start_delta > 0:
64
+ parts.append(f"start delayed by {_format_us(start_delta)}")
65
+ elif start_delta < 0:
66
+ parts.append(f"start moved earlier by {_format_us(-start_delta)}")
67
+
68
+ if end_delta > 0:
69
+ parts.append(f"end extended by {_format_us(end_delta)}")
70
+ elif end_delta < 0:
71
+ parts.append(f"end trimmed by {_format_us(-end_delta)}")
72
+
73
+ return ", ".join(parts) or "timing unchanged"
74
+
75
+
76
+ def _change_shift_stats(change: dict) -> dict:
77
+ start_delta = change["new_start"] - change["orig_start"]
78
+ end_delta = change["new_end"] - change["orig_end"]
79
+ return {
80
+ "start_delayed": max(0.0, start_delta),
81
+ "start_earlier": max(0.0, -start_delta),
82
+ "end_extended": max(0.0, end_delta),
83
+ "end_trimmed": max(0.0, -end_delta),
84
+ "boundary_shift": abs(start_delta) + abs(end_delta),
85
+ }
86
+
87
+
88
+ def _summarize_changes_by_name(changes: list[dict]) -> list[dict]:
89
+ summaries = {}
90
+ for change in changes:
91
+ summary = summaries.setdefault(
92
+ change["name"],
93
+ {
94
+ "name": change["name"],
95
+ "runs": 0,
96
+ "start_delayed": 0.0,
97
+ "start_earlier": 0.0,
98
+ "end_extended": 0.0,
99
+ "end_trimmed": 0.0,
100
+ "boundary_shift": 0.0,
101
+ "max_boundary_shift": 0.0,
102
+ "moved": 0,
103
+ "snapped": 0,
104
+ },
105
+ )
106
+ shifts = _change_shift_stats(change)
107
+ summary["runs"] += 1
108
+ summary["start_delayed"] += shifts["start_delayed"]
109
+ summary["start_earlier"] += shifts["start_earlier"]
110
+ summary["end_extended"] += shifts["end_extended"]
111
+ summary["end_trimmed"] += shifts["end_trimmed"]
112
+ summary["boundary_shift"] += shifts["boundary_shift"]
113
+ summary["max_boundary_shift"] = max(summary["max_boundary_shift"], shifts["boundary_shift"])
114
+ summary["moved"] += int(change["orig_tid"] != change["new_tid"])
115
+ summary["snapped"] += int(change["snap_kind"] is not None)
116
+
117
+ return sorted(
118
+ summaries.values(),
119
+ key=lambda summary: (
120
+ -summary["boundary_shift"],
121
+ -summary["runs"],
122
+ -summary["moved"],
123
+ -summary["snapped"],
124
+ -summary["max_boundary_shift"],
125
+ summary["name"],
126
+ ),
127
+ )
128
+
129
+
130
+ def _allocate_lane_tid(pid, tid, lane_idx: int, used_tracks: set[tuple]) -> int | str:
131
+ if type(tid) is int:
132
+ candidate = tid * 1000 + lane_idx
133
+ if (pid, candidate) not in used_tracks:
134
+ used_tracks.add((pid, candidate))
135
+ return candidate
136
+
137
+ base = f"{tid} lane {lane_idx}"
138
+ candidate = base
139
+ suffix = 2
140
+ while (pid, candidate) in used_tracks:
141
+ candidate = f"{base} #{suffix}"
142
+ suffix += 1
143
+
144
+ used_tracks.add((pid, candidate))
145
+ return candidate
146
+
147
+
148
+ def fix_overlaps(in_path: str, out_path: str, snap_threshold_us: float = SNAP_THRESHOLD_US) -> None:
149
+ if snap_threshold_us < 0:
150
+ raise ValueError("snap_threshold_us must be non-negative")
151
+
152
+ with open(in_path) as f:
153
+ trace = json.load(f)
154
+
155
+ events = trace.get("traceEvents", trace) if isinstance(trace, dict) else trace
156
+
157
+ slices, flows, others = [], [], []
158
+ for e in events:
159
+ ph = e.get("ph")
160
+ if ph == "X" and "dur" in e:
161
+ slices.append(e)
162
+ elif ph in ("s", "t", "f"):
163
+ flows.append(e)
164
+ else:
165
+ others.append(e)
166
+
167
+ used_tracks = {
168
+ (e.get("pid"), e.get("tid"))
169
+ for e in events
170
+ if "pid" in e and "tid" in e
171
+ }
172
+
173
+ by_track = defaultdict(list)
174
+ for e in slices:
175
+ by_track[(e.get("pid"), e.get("tid"))].append(e)
176
+ print(f"processing {len(by_track)} track(s)")
177
+
178
+ lane_names = {}
179
+ lane_tids = {}
180
+ endpoint_rewrites = {}
181
+ snapped_total = 0
182
+ per_track_report = []
183
+ changed_slices = []
184
+
185
+ for (pid, tid), group in by_track.items():
186
+ # Outer-first: earliest start, longest dur on ties, so parents are
187
+ # placed before their children.
188
+ group.sort(key=lambda e: (e["ts"], -e["dur"]))
189
+ lanes = [] # each lane is a stack of (end_ts, start_ts)
190
+ moved = 0
191
+ snapped = 0
192
+
193
+ for e in group:
194
+ orig_tid = e["tid"]
195
+ orig_start = e["ts"]
196
+ orig_dur = e["dur"]
197
+ orig_end = orig_start + orig_dur
198
+ start = orig_start
199
+ end = start + orig_dur
200
+ placed_lane = None
201
+ snap_kind = None
202
+
203
+ for lane_idx, stack in enumerate(lanes):
204
+ placement_resolved = False
205
+ while not placement_resolved:
206
+ while stack and stack[-1][0] <= start:
207
+ stack.pop()
208
+
209
+ if not stack:
210
+ stack.append((end, start))
211
+ placed_lane = lane_idx
212
+ placement_resolved = True
213
+ break
214
+
215
+ top_end, top_start = stack[-1]
216
+
217
+ if lane_idx != 0:
218
+ if top_start <= start and end <= top_end:
219
+ stack.append((end, start))
220
+ placed_lane = lane_idx
221
+ placement_resolved = True
222
+ break # Only snap on the primary lane
223
+
224
+ if start < top_start:
225
+ start_shift = top_start - start
226
+ if start_shift < snap_threshold_us:
227
+ start = top_start
228
+ e["ts"] = top_start
229
+ e["dur"] = max(0.0, end - start)
230
+ snapped += 1
231
+ snap_kind = "start-snap"
232
+ continue
233
+ break
234
+
235
+ if end <= top_end:
236
+ stack.append((end, start))
237
+ placed_lane = lane_idx
238
+ placement_resolved = True
239
+ break
240
+
241
+ end_shift = end - top_end
242
+ start_shift = top_end - start
243
+
244
+ if 0 < end_shift <= start_shift and end_shift < snap_threshold_us:
245
+ end = top_end
246
+ e["dur"] = max(0.0, end - start)
247
+ snapped += 1
248
+ snap_kind = "end-snap"
249
+ elif 0 < start_shift < snap_threshold_us:
250
+ start = top_end
251
+ e["ts"] = top_end
252
+ e["dur"] = max(0.0, end - start)
253
+ snapped += 1
254
+ snap_kind = "start-snap"
255
+ else:
256
+ break # Cannot snap, try next lane
257
+
258
+ if placed_lane is not None:
259
+ break
260
+
261
+ if placed_lane is None:
262
+ placed_lane = len(lanes)
263
+ lanes.append([(end, start)])
264
+
265
+ if placed_lane > 0:
266
+ lane_key = (pid, tid, placed_lane)
267
+ if lane_key not in lane_tids:
268
+ lane_tids[lane_key] = _allocate_lane_tid(pid, tid, placed_lane, used_tracks)
269
+ new_tid = lane_tids[lane_key]
270
+ e["tid"] = new_tid
271
+ lane_names[(pid, new_tid)] = (tid, placed_lane)
272
+ moved += 1
273
+
274
+ new_start = e["ts"]
275
+ new_dur = e["dur"]
276
+ new_end = new_start + new_dur
277
+
278
+ if e["tid"] != orig_tid or new_start != orig_start or new_dur != orig_dur:
279
+ changed_slices.append({
280
+ "name": e.get("name", "<unnamed>"),
281
+ "cat": e.get("cat", ""),
282
+ "pid": pid,
283
+ "orig_tid": orig_tid,
284
+ "new_tid": e["tid"],
285
+ "orig_start": orig_start,
286
+ "new_start": new_start,
287
+ "orig_dur": orig_dur,
288
+ "new_dur": new_dur,
289
+ "orig_end": orig_end,
290
+ "new_end": new_end,
291
+ "snap_kind": snap_kind,
292
+ "lane_idx": placed_lane,
293
+ })
294
+
295
+ if e["tid"] != orig_tid or new_start != orig_start:
296
+ endpoint_rewrites[(pid, orig_tid, orig_start)] = (e["tid"], e["ts"])
297
+
298
+ snapped_total += snapped
299
+ if len(lanes) > 1 or snapped:
300
+ per_track_report.append((pid, tid, len(lanes), moved, snapped))
301
+
302
+ for pid, tid, n_lanes, moved, snapped in per_track_report:
303
+ print(f" pid={pid} tid={tid}: {n_lanes} lane(s), {moved} slice(s) moved, {snapped} snapped")
304
+ print(f"snapped {snapped_total} sub-{snap_threshold_us}us crossing(s)")
305
+
306
+ if changed_slices:
307
+ total_start_shift = sum(abs(c["new_start"] - c["orig_start"]) for c in changed_slices)
308
+ total_end_shift = sum(abs(c["new_end"] - c["orig_end"]) for c in changed_slices)
309
+
310
+ print(
311
+ f"updated {len(changed_slices)} slice(s): "
312
+ f"total |start shift|={_format_us(total_start_shift)}, "
313
+ f"total |end shift|={_format_us(total_end_shift)}, "
314
+ f"total |boundary shift|={_format_us(total_start_shift + total_end_shift)}"
315
+ )
316
+
317
+ kernel_changes = [c for c in changed_slices if c["cat"] == "kernel"]
318
+ if kernel_changes:
319
+ kernel_summaries = _summarize_changes_by_name(kernel_changes)
320
+ print(
321
+ "kernel timing summary "
322
+ f"({len(kernel_changes)} run(s) across {len(kernel_summaries)} kernel name(s)):"
323
+ )
324
+ for summary in kernel_summaries:
325
+ print(
326
+ f" {summary['name']}: runs={summary['runs']}, "
327
+ f"total delayed={_format_us(summary['start_delayed'])}, "
328
+ f"total trimmed={_format_us(summary['end_trimmed'])}, "
329
+ f"total boundary shift={_format_us(summary['boundary_shift'])}, "
330
+ f"moved={summary['moved']}, snapped={summary['snapped']}"
331
+ )
332
+
333
+ categories = [
334
+ ("moved slices", lambda c: c["cat"] != "kernel" and c["orig_tid"] != c["new_tid"]),
335
+ ("snapped slices", lambda c: c["cat"] != "kernel" and c["snap_kind"] is not None),
336
+ ]
337
+
338
+ for title, condition in categories:
339
+ filtered = [c for c in changed_slices if condition(c)]
340
+ if filtered:
341
+ print(f"{title} ({len(filtered)}):")
342
+ for c in filtered:
343
+ tid_info = f" tid={c['new_tid']}" if title == "snapped slices" else ""
344
+ print(f" {c['name']} pid={c['pid']}{tid_info}: {_describe_change(c)}")
345
+
346
+ flow_rewrites = 0
347
+ for fe in flows:
348
+ key = (fe.get("pid"), fe.get("tid"), fe.get("ts"))
349
+ if key in endpoint_rewrites:
350
+ fe["tid"], fe["ts"] = endpoint_rewrites[key]
351
+ flow_rewrites += 1
352
+
353
+ print(f"rewrote {flow_rewrites} flow endpoint(s) onto rewritten slice starts/lanes")
354
+
355
+ for (pid, new_tid), (orig_tid, lane_idx) in lane_names.items():
356
+ others.extend([
357
+ {
358
+ "name": "thread_name", "ph": "M", "pid": pid, "tid": new_tid,
359
+ "args": {"name": f"tid {orig_tid} lane {lane_idx}"},
360
+ },
361
+ {
362
+ "name": "thread_sort_index", "ph": "M", "pid": pid, "tid": new_tid,
363
+ "args": {"sort_index": lane_idx},
364
+ }
365
+ ])
366
+
367
+ out_events = others + slices + flows
368
+ if isinstance(trace, dict):
369
+ trace["traceEvents"] = out_events
370
+ else:
371
+ trace = out_events
372
+
373
+ with open(out_path, "w") as f:
374
+ json.dump(trace, f, indent=2)
375
+ f.write("\n")
376
+
377
+ print(f"wrote {out_path}: {len(slices)} slices, {len(lane_names)} new lanes")
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: trace-lanefix
3
+ Version: 0.1.0
4
+ Summary: Fix overlapping Chrome/Kineto trace slices so Perfetto can render them.
5
+ Author: Roman Ralovets
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/ralovets/trace-lanefix
8
+ Project-URL: Issues, https://github.com/ralovets/trace-lanefix/issues
9
+ Keywords: chrome-trace,kineto,perfetto,profiling,pytorch
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Debuggers
18
+ Classifier: Topic :: System :: Benchmark
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # trace-lanefix
27
+
28
+ `trace-lanefix` rewrites Chrome/Kineto trace JSON so Perfetto can render slices
29
+ that would otherwise be dropped because of crossing overlaps on the same
30
+ `(pid, tid)` track.
31
+
32
+ It keeps properly nested slices on the same track, snaps tiny timestamp-boundary
33
+ crossings, and moves genuine overlaps to synthetic lanes with collision-free
34
+ thread ids.
35
+
36
+ ## Before / After
37
+
38
+ Before:
39
+
40
+ <img src="https://raw.githubusercontent.com/ralovets/trace-lanefix/main/assets/profile-before.png" alt="Profile before lane fix" width="100%">
41
+
42
+ After:
43
+
44
+ <img src="https://raw.githubusercontent.com/ralovets/trace-lanefix/main/assets/profile-after.png" alt="Profile after lane fix" width="100%">
45
+
46
+ In Perfetto's trace overview, the rewritten trace reports `TRACE ERRORS=0`
47
+ instead of `TRACE ERRORS=6`, while `IMPORT ERRORS` and `DATA LOSSES` remain
48
+ at `0`. In this example, the JSON trace size changed from `452.76 KiB` before
49
+ rewriting (`463,629` bytes) to `653.96 KiB` after rewriting (`669,651`
50
+ bytes) because the output is pretty-printed for readability.
51
+
52
+ ## CLI
53
+
54
+ ```bash
55
+ trace-lanefix input.json output.fixed.json
56
+ ```
57
+
58
+ Tiny crossings below `5.0us` are snapped by default. To use a different snap
59
+ threshold:
60
+
61
+ ```bash
62
+ trace-lanefix --snap-threshold-us 2.5 input.json output.fixed.json
63
+ ```
64
+
65
+ Example output:
66
+
67
+ This run shows the two repair paths: tiny sub-5.0us timestamp crossings are
68
+ snapped in place, while one genuine overlap is moved to a synthetic lane. Flow
69
+ endpoints are then rewritten to follow the adjusted slice starts and lanes.
70
+
71
+ ```text
72
+ trace-lanefix trace.json trace_fixed.json
73
+ processing 3 track(s)
74
+ pid=7334 tid=7334: 2 lane(s), 1 slice(s) moved, 11 snapped
75
+ pid=0 tid=7: 1 lane(s), 0 slice(s) moved, 5 snapped
76
+ snapped 16 sub-5.0us crossing(s)
77
+ updated 15 slice(s): total |start shift|=6.195us, total |end shift|=5.240us, total |boundary shift|=11.436us
78
+ kernel timing summary (5 run(s) across 3 kernel name(s)):
79
+ nvjet_sm120_tst_mma_256x112x64_2_64x56x64_tmaAB_bz_TNNN: runs=2, total delayed=2.848us, total trimmed=0.000us, total boundary shift=2.848us, moved=0, snapped=2
80
+ nvjet_sm120_tst_mma_96x256x64_2_48x64x64_tmaAB_bz_TNNN: runs=2, total delayed=480ns, total trimmed=0.000us, total boundary shift=480ns, moved=0, snapped=2
81
+ nvjet_sm120_tst_mma_128x256x64_2_64x64x64_tmaAB_bz_TNNN: runs=1, total delayed=384ns, total trimmed=0.000us, total boundary shift=384ns, moved=0, snapped=1
82
+ moved slices (1):
83
+ ProfilerStep#5 pid=7334: moved to lane 1 (tid 7334 -> 7334001)
84
+ snapped slices (9):
85
+ layer0_forward pid=7334 tid=7334: start delayed by 2.483us
86
+ torch/autograd/profiler.py(843): __exit__ pid=7334 tid=7334: end trimmed by 2.855us
87
+ torch/_ops.py(1079): __call__ pid=7334 tid=7334: end trimmed by 1.222us
88
+ <built-in method of pybind11_builtins.pybind11_detail_function_record_v1_system_libstdcpp_gxx_abi_1xxx_use_cxx11_abi_1 object at 0x719b5a7004b0> pid=7334 tid=7334: end trimmed by 1.162us
89
+ contextlib.py(141): __exit__ pid=7334 tid=7334: end trimmed by 0.2ns
90
+ profiling.py(19): trace pid=7334 tid=7334: end trimmed by 0.2ns
91
+ torch/profiler/profiler.py(912): _transit_action pid=7334 tid=7334: end trimmed by 0.2ns
92
+ torch/cuda/__init__.py(1161): synchronize pid=7334 tid=7334: end trimmed by 0.2ns
93
+ torch/cuda/__init__.py(609): __exit__ pid=7334 tid=7334: end trimmed by 0.2ns
94
+ rewrote 5 flow endpoint(s) onto rewritten slice starts/lanes
95
+ wrote trace_fixed.json: 988 slices, 1 new lanes
96
+ ```
97
+
98
+ ## Python API
99
+
100
+ ```python
101
+ from trace_lanefix import fix_overlaps
102
+
103
+ fix_overlaps("input.json", "output.fixed.json")
104
+ fix_overlaps("input.json", "output.fixed.json", snap_threshold_us=2.5)
105
+ ```
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ tests/test_fix_overlaps.py
5
+ trace_lanefix/__init__.py
6
+ trace_lanefix/cli.py
7
+ trace_lanefix/core.py
8
+ trace_lanefix.egg-info/PKG-INFO
9
+ trace_lanefix.egg-info/SOURCES.txt
10
+ trace_lanefix.egg-info/dependency_links.txt
11
+ trace_lanefix.egg-info/entry_points.txt
12
+ trace_lanefix.egg-info/requires.txt
13
+ trace_lanefix.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ trace-lanefix = trace_lanefix.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [dev]
3
+ pytest>=8
@@ -0,0 +1 @@
1
+ trace_lanefix